Compare commits

..

49 Commits

Author SHA1 Message Date
felixm 29c0fc92d0 Merge branch 'tui-nav-archiving'
TUI h/l column navigation and recoverable session archiving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 12:37:31 -04:00
felixm d9b7c001ef docs: document tmux.conf bindings and new nav/archive keys in README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 12:31:52 -04:00
felixm 638d27bd21 feat: u restores archived sessions; x/u gated by current view 2026-06-11 12:29:24 -04:00
felixm 167695708b feat: A toggles the sessions column between active and archived
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 12:22:41 -04:00
felixm 6724d5e128 feat: archive session action bound to x
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 12:18:11 -04:00
felixm 8ee73ab8e3 feat: SessionList.set_mode swaps header for archived view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 12:15:58 -04:00
felixm dfc7ffeaaf feat: h/l focus navigation between project and session columns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 12:12:16 -04:00
felixm 2a56d6584b feat: add archive_session and unarchive_session to SessionService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 12:08:23 -04:00
felixm c8380be72d feat: parameterize list_sessions with archived filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 12:05:19 -04:00
felixm 138e4e04bc docs: finalize TUI nav + archiving spec and implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 12:02:45 -04:00
felixm 32f7e0ded7 docs: design spec for TUI h/l nav and session archiving
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:22:14 -04:00
felixm fc95bf9830 feat: stop and rename a session from its tmux window
Add "stop" and "rename" to the Alt+p tool palette so a session can be
managed from inside its own harness window, mirroring the TUI's s/r keys:

- stop kills the harness window (stop_session_for_window).
- rename prompts for a nickname, pre-filled with the current one. The
  name is read from /dev/tty via readline (the fzf pipe leaves our stdin
  spent) and never passes through tmux's command parser, so there's
  nothing to quote-escape.

Extract require_session_id_for_window as the shared resolve-or-raise
guard behind the tool/stop/rename branches, dropping the duplicated
"not an hqt session window" check.

Also removes the now-shipped feature plans and specs under docs/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:07:33 -04:00
felixm ff357156d0 Merge branch 'new-session-dialog-ux'
New Session dialog UX refinements: reveal sandbox sub-options only when
sandboxing is on, unify focus highlight to the Peach accent, and add soft
j/k field navigation.
2026-06-11 10:50:47 -04:00
felixm 98a894f752 test: harden sandbox policy mapping across switch states
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:49:03 -04:00
felixm 78ea959788 feat: soft j/k field navigation in New Session dialog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:43:00 -04:00
felixm d72f04dd89 style: unify New Session dialog focus highlight to accent
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:38:09 -04:00
felixm bafc664251 feat: reveal sandbox sub-options only when sandboxing is on
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:36:57 -04:00
felixm 783bfee79f docs: implementation plan for New Session dialog UX refinements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:29:35 -04:00
felixm dbaf8ab1de docs: spec for New Session dialog UX refinements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:26:37 -04:00
felixm 5b4e800883 feat: add reset-windows entry to the tool palette
Hook the existing tmux window-index reset into the Alt+p tool palette,
special-cased like clone: selecting 'reset-windows' compacts the hqt
session's window indexes (keeping the home window at 0) via
TmuxManager.reset_window_indexes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:14:04 -04:00
felixm f5c97d1fe0 Merge branch 'tool-windows'
Add tool windows: tool registry, styled aux tmux windows
(TmuxRunner.new_aux_window / TmuxManager.open_aux_window),
SessionService tool-window + clone-for-window methods, and
hqt tool / hqt palette CLI subcommands (fzf tool launcher).

# Conflicts:
#	src/hqt/cli.py
#	src/hqt/tmux/manager.py
#	tests/test_cli.py
#	tests/test_sessions.py
2026-06-11 09:43:37 -04:00
felixm 75a0dc70bc test: verify delayed prompt injection
Fix pre-existing ty error in action_new_session by typing the sandbox
element as SandboxPolicy | None instead of object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:08:54 -04:00
felixm 2e58b191fa feat: wire delayed prompt TUI actions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:06:36 -04:00
felixm dfea5a5630 feat: show scheduled prompts in session rows
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:04:26 -04:00
felixm 7ebc3bf560 fix: preserve resume setup for scheduled dispatch 2026-06-11 08:50:54 -04:00
felixm 7109be8cbf test: cover schedule prompt modal validation 2026-06-11 08:48:35 -04:00
felixm ac7d57bfeb feat: add delayed prompt modal 2026-06-11 08:41:39 -04:00
felixm f10264f695 feat: dispatch due scheduled prompts 2026-06-11 08:40:16 -04:00
felixm 6c7e4e4682 Merge branch 'sandboxed-sessions' 2026-06-11 08:35:14 -04:00
felixm 488d76e88d Disable allow-rename on tool windows (match new_window)
nvim/lazygit emit OSC title sequences; without allow-rename off a tool
could overwrite the window name and scramble the styled @hqt_label.
Mirrors the window-option setup in new_window. Surfaced by final
integration review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:28:07 -04:00
felixm 88a52d0e6d fix: handle scheduled prompts on session deletion 2026-06-11 08:26:33 -04:00
felixm 4169e7fc28 feat: schedule prompts in session service 2026-06-11 08:18:44 -04:00
felixm 5f29418186 fix: make sandboxed sessions usable for real git workflows
Address three integration gaps found in review:

- Bind ~/.gitconfig read-only in the sandbox base layer so `git commit`
  finds user.name/email inside the jail (spec §3).
- Bind a worktree session's repo common .git dir writable: the worktree's
  real gitdir lives at <repo>/.git/worktrees/<branch>, outside the bound
  cwd, so git was broken in sandboxed worktree sessions.
- doctor now gates on sandbox.is_available() (the single source of truth
  shared with the New Session dialog) rather than a raw shutil.which, so
  it agrees with the service on non-Linux platforms (spec §6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:16:48 -04:00
felixm 60612065ff feat: persist scheduled prompts 2026-06-10 22:55:18 -04:00
felixm c574117c71 feat: report tmux prompt injection failures 2026-06-10 22:54:48 -04:00
felixm a5f2f69faa docs: plan delayed prompt injection 2026-06-10 22:46:55 -04:00
felixm 90db743922 docs: design delayed prompt injection 2026-06-10 22:39:44 -04:00
felixm 09505f0758 fix: keep tmux focus during window index reset 2026-06-10 22:18:13 -04:00
felixm ee4bb4cbfa feat: expose tmux window index reset 2026-06-10 22:02:09 -04:00
felixm bebdf8c1ee feat: reset tmux window indexes 2026-06-10 21:56:21 -04:00
felixm 666df40aca test: cover tmux window index reset 2026-06-10 21:51:16 -04:00
felixm 9b515f6b44 docs: design tmux window index reset 2026-06-10 21:39:31 -04:00
felixm 7d851dcb7e feat: doctor reports bubblewrap availability
Adds a non-fatal bubblewrap check to the doctor command that reports whether
the bwrap binary is available. This is necessary for sandboxed sessions and
the check is displayed alongside other system requirement checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:37:30 -04:00
felixm f6ed53ce23 feat: sandbox toggles in New Session dialog, gated on bwrap availability
Adds sandbox_available param to NewSessionScreen, renders Switch/Select/
Switch widgets for sandbox on/off, filesystem access (rw/ro), and network
toggle. When bwrap is unavailable the switch is disabled and a warning label
is shown. The _policy_from staticmethod maps widget values to SandboxPolicy.
The result tuple gains SandboxPolicy | None as its fifth element. app.py
passes sandbox_available=sandbox.is_available() when opening the dialog and
unpacks the new element to forward sandbox= to create_session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:33:34 -04:00
felixm e54cbb96b6 feat: wrap sandboxed sessions in bwrap on spawn and resume
- Import `hqt.sandbox` module and `SandboxPolicy` in service.py
- Add `_sandbox_policy` helper to decode persisted JSON policy from a session row
- Add `_wrap_command` helper that calls `sandbox.is_available` / `sandbox.wrap` and raises `ServiceError` when bwrap is unavailable for a sandboxed session
- Add `sandbox: SandboxPolicy | None = None` parameter to `create_session`; persist as `sandbox_json`, pass `sandboxed=` to `build_spawn_config`, wrap command before spawning
- Update `_get_resume_config` to pass `sandboxed=` to `build_resume_config` and wrap the resulting command
- Update rung-2 path in `_respawn_with_fallback` to pass `sandboxed=` and wrap the fresh-spawn command
- Add three new tests covering: sandboxed create wraps command, unsandboxed create does not wrap, and ServiceError on missing bwrap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:28:22 -04:00
felixm 080ddd9364 feat: add Session.sandbox_json column and migration
Adds a nullable sandbox_json TEXT column to the sessions table via a new
version-3 migration. Updates LATEST_VERSION to 3 and adjusts existing
migration tests to account for the new column and version number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:23:45 -04:00
felixm 4c36385045 feat: configurators declare sandbox skip-permission flags and binds
Add sandbox_skip_permission_flags class attr and sandbox_binds() method
to HarnessConfigurator ABC; thread sandboxed: bool = False through all
build_spawn_config / build_resume_config signatures. Claude and Codex
append their respective skip-permission flags when sandboxed=True; Kiro
and Generic accept the param but add no flags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:20:50 -04:00
felixm 9db79c68f4 feat: sandbox.wrap builds bwrap argv from policy and binds
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:16:44 -04:00
felixm 3130d95cc5 feat: sandbox primitives — Bind, SandboxPolicy, is_available
Core sandbox infrastructure for isolating harness sessions. Bind defines
read-only/writable path bindings, SandboxPolicy specifies filesystem and
network isolation modes, and is_available() is the single source of truth
for bubblewrap availability across the application.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:15:30 -04:00
40 changed files with 4039 additions and 7046 deletions
+56
View File
@@ -31,6 +31,62 @@ hqt
| `Enter` | Attach to session (auto-resumes if not running) | | `Enter` | Attach to session (auto-resumes if not running) |
| `r` | Rename session | | `r` | Rename session |
| `s` | Stop session | | `s` | Stop session |
| `h` / `l` | Focus the Projects / Sessions column |
| `x` | Archive selected session (instant; keeps the row, kills the window) |
| `A` | Toggle the Sessions column between active and archived |
| `u` | Restore (unarchive) the selected session — archived view only |
## tmux configuration
hqt runs inside tmux and tags each harness window with an `@hqt_label` user
option automatically — **it never edits your `~/.tmux.conf`**. The TUI itself
needs no tmux config to run. The bindings below are opt-in: add the ones you
want to your own `~/.tmux.conf` (`-n` = no prefix key).
### Tool palette — `Alt+p` (required for the palette feature)
Pops an fzf palette for the current session's project (nvim / lazygit / shell /
clone / reset-windows) from inside a harness window. Requires `hqt` on your
`PATH` (see Quick Start) and `fzf`. `run-shell` expands `#{window_name}` to a
concrete window name before `hqt` builds its `display-popup`:
```tmux
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
```
If you run hqt from a checkout instead of installing it, point `uv run` at the
repo so the virtualenv self-heals when deps change:
```tmux
bind -n M-p run-shell -b "uv run --project /path/to/hq-term hqt palette '#{window_name}'"
```
### Session switcher — `Alt+o` (optional)
Pops an fzf switcher over whatever you're doing (works inside a harness too).
Rows are built from the `@hqt_label` hqt sets; `cut` takes the leading window
index and `select-window` jumps to it:
```tmux
bind -n M-o display-popup -E -w 50% -h 40% -T ' switch session ' \
"tmux list-windows -F '#{window_index} #{?@hqt_label,#{@hqt_label},#{window_name}}' \
| fzf --reverse --no-info --prompt='session ' \
| cut -d' ' -f1 \
| xargs -r tmux select-window -t"
```
### Scrollback passthrough — `PageUp` (optional)
The TUI uses tmux's alternate screen. This sends `PageUp` straight through to
full-screen apps (the TUI) while still entering copy-mode scrollback in normal
panes:
```tmux
bind -n PageUp if-shell -F '#{alternate_on}' 'send-keys PageUp' 'copy-mode -eu'
```
After editing `~/.tmux.conf`, reload it with `tmux source-file ~/.tmux.conf`
(or restart the server).
## Worktree-isolated sessions ## Worktree-isolated sessions
@@ -1,808 +0,0 @@
# 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"
```
File diff suppressed because it is too large Load Diff
@@ -1,822 +0,0 @@
# P2 Fixes 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:** Resolve the three P2 findings in `TODO.md` — validate project paths at create/update, harden Codex session-id capture against the multi-session race, and add isolated real-tmux smoke tests.
**Architecture:** Three independent fixes. (1) `ProjectService` gains a `_normalize_path` helper that rejects non-directories. (2) `CodexConfigurator.capture_session_id` refuses to guess when more than one rollout matches, and `SessionService` serializes the spawn→capture window with an `asyncio.Lock` for capturing harnesses. (3) A new `tests/test_tmux_smoke.py` drives the real `TmuxRunner` against a throwaway tmux server isolated via `TMUX_TMPDIR`, auto-skipping when tmux is absent.
**Tech Stack:** Python 3.12, SQLAlchemy 2.0, Textual, pytest + pytest-asyncio, `uv`, tmux.
**Design spec:** `docs/superpowers/specs/2026-06-10-p2-fixes-design.md`
**Conventions for every task:**
- Run quality gates after code/test changes: `uv run ruff format src tests && uv run ruff check src tests && uv run ty check`.
- Run tests with `uv run pytest`.
- Commit messages end with the trailer:
```
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
```
- **NEVER `git add -A` or `git add .`** — `src/hqt/tmux/runner.py` carries unrelated uncommitted WIP that must stay out of every commit. Always `git add` the exact files listed in the task. No task in this plan modifies `runner.py`.
- Work on `main`, committing directly to it.
---
## File Structure
| File | Responsibility | Tasks |
|------|----------------|-------|
| `src/hqt/projects/service.py` | Project CRUD; add path normalization/validation | 1 |
| `tests/test_services.py` | ProjectService tests; add path-validation cases | 1 |
| `src/hqt/harnesses/configurators/codex.py` | Codex capture; refuse ambiguous matches | 2 |
| `tests/test_harnesses.py` | Codex capture tests; replace earliest-wins cases with ambiguity case | 2 |
| `src/hqt/sessions/service.py` | Session lifecycle; serialize spawn→capture with a lock | 3 |
| `tests/test_sessions.py` | SessionService tests; assert lock behavior | 3 |
| `tests/test_tmux_smoke.py` (new) | Real-tmux smoke tests on an isolated server | 4 |
| `pyproject.toml` | Register the `tmux` pytest marker | 4 |
| `TODO.md` | Remove the three resolved P2 lines | 5 |
---
## Task 1: Project path validation
**Files:**
- Modify: `src/hqt/projects/service.py`
- Test: `tests/test_services.py`
- [ ] **Step 1: Write the failing tests**
Add these tests to the `TestProjectService` class in `tests/test_services.py`. They need a real directory, so they use pytest's `tmp_path` fixture.
```python
def test_create_rejects_nonexistent_path(self, factory, tmp_path):
svc = ProjectService(factory)
missing = tmp_path / "does-not-exist"
with pytest.raises(ServiceError, match="does not exist or is not a directory"):
svc.create("p", str(missing))
def test_create_rejects_file_path(self, factory, tmp_path):
svc = ProjectService(factory)
a_file = tmp_path / "afile"
a_file.write_text("x")
with pytest.raises(ServiceError, match="does not exist or is not a directory"):
svc.create("p", str(a_file))
def test_create_stores_resolved_absolute_path(self, factory, tmp_path):
svc = ProjectService(factory)
p = svc.create("p", str(tmp_path))
assert p.path == str(tmp_path.resolve())
def test_create_expands_user_home(self, factory, tmp_path, monkeypatch):
# Point ~ at tmp_path; "~" must expand to the existing tmp_path dir.
monkeypatch.setenv("HOME", str(tmp_path))
svc = ProjectService(factory)
p = svc.create("p", "~")
assert p.path == str(tmp_path.resolve())
def test_update_rejects_nonexistent_path_and_leaves_row(self, factory, tmp_path):
svc = ProjectService(factory)
p = svc.create("p", str(tmp_path))
missing = tmp_path / "nope"
with pytest.raises(ServiceError, match="does not exist or is not a directory"):
svc.update(p.id, "p", str(missing))
assert svc.get(p.id).path == str(tmp_path.resolve())
```
Several existing tests pass made-up paths like `/tmp/test`, `/a`, `/old/path` that are not guaranteed to exist. Update those literals to real directories so they keep passing. Replace the existing `TestProjectService` methods `test_create_and_get`, `test_list_excludes_archived`, `test_update_name_and_path`, `test_update_duplicate_path_raises`, and `test_create_duplicate_path_raises_service_error` to seed paths from `tmp_path`:
```python
def test_create_and_get(self, factory, tmp_path):
svc = ProjectService(factory)
p = svc.create("test", str(tmp_path))
assert p.id is not None
assert svc.get(p.id).name == "test"
def test_list_excludes_archived(self, factory, tmp_path):
a = tmp_path / "a"
a.mkdir()
b = tmp_path / "b"
b.mkdir()
svc = ProjectService(factory)
svc.create("a", str(a))
p2 = svc.create("b", str(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, factory, tmp_path):
old = tmp_path / "old"
old.mkdir()
new = tmp_path / "new"
new.mkdir()
svc = ProjectService(factory)
p = svc.create("old", str(old))
updated = svc.update(p.id, "new", str(new))
assert updated.name == "new"
assert updated.path == str(new.resolve())
assert svc.get(p.id).path == str(new.resolve())
def test_update_duplicate_path_raises(self, factory, tmp_path):
a = tmp_path / "a"
a.mkdir()
b = tmp_path / "b"
b.mkdir()
svc = ProjectService(factory)
svc.create("a", str(a))
p2 = svc.create("b", str(b))
with pytest.raises(ServiceError, match="already uses path"):
svc.update(p2.id, "b", str(a))
# DB session must remain usable after rollback, values unchanged
assert svc.get(p2.id).path == str(b.resolve())
assert svc.get(p2.id).name == "b"
def test_create_duplicate_path_raises_service_error(self, factory, tmp_path):
dup = tmp_path / "dup"
dup.mkdir()
svc = ProjectService(factory)
svc.create("a", str(dup))
with pytest.raises(ServiceError, match="already uses path"):
svc.create("b", str(dup))
```
Note: `TestMcpService.test_bind_unbind` creates a project at `/proj`; change that literal to `str(tmp_path)` and add `tmp_path` to its signature:
```python
def test_bind_unbind(self, factory, tmp_path):
psvc = ProjectService(factory)
msvc = McpService(factory)
project = psvc.create("proj", str(tmp_path))
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
```
- [ ] **Step 2: Run the new tests to verify they fail**
Run: `uv run pytest tests/test_services.py -k "rejects or resolved or expands_user" -v`
Expected: FAIL — `ServiceError` not raised / path not resolved (validation not implemented yet).
- [ ] **Step 3: Implement `_normalize_path` and call it from `create`/`update`**
In `src/hqt/projects/service.py`, add the `Path` import and the helper, and call it at the top of `create` and `update`. Full file:
```python
from pathlib import Path
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from hqt.db.models import Project
from hqt.errors import ServiceError
class ProjectService:
def __init__(self, factory: sessionmaker):
# factory must be created with expire_on_commit=False so rows returned
# from these methods stay readable after their session closes.
self.factory = factory
def _normalize_path(self, raw: str) -> str:
"""Expand ~, require an existing directory, return the resolved absolute path.
Raises ServiceError if the path does not exist or is not a directory, so
the TUI surfaces it as a notification instead of letting the bad path
become a dead session later.
"""
path = Path(raw).expanduser()
if not path.is_dir():
raise ServiceError(f"Path does not exist or is not a directory: {raw}")
return str(path.resolve())
def create(self, name: str, path: str) -> Project:
path = self._normalize_path(path)
with self.factory() as db:
project = Project(name=name, path=path)
db.add(project)
try:
db.commit()
except IntegrityError as err:
db.rollback()
raise ServiceError(f"Another project already uses path {path}") from err
return project
def list_all(self, include_archived: bool = False) -> list[Project]:
with self.factory() as db:
q = 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:
with self.factory() as db:
return db.get(Project, project_id)
def update(self, project_id: int, name: str, path: str) -> Project:
path = self._normalize_path(path)
with self.factory() as db:
project = db.get(Project, project_id)
if project is None:
raise ServiceError(f"Project {project_id} not found")
project.name = name
project.path = path
try:
db.commit()
except IntegrityError as err:
db.rollback()
raise ServiceError(f"Another project already uses path {path}") from err
return project
def archive(self, project_id: int) -> None:
with self.factory() as db:
project = db.get(Project, project_id)
if project:
project.archived = True
db.commit()
```
Note ordering: `_normalize_path` runs **before** opening the DB session, so a bad path is rejected without touching the DB. `update` validates the path before checking the project exists — that's fine; both raise `ServiceError`.
- [ ] **Step 4: Run the full service test file**
Run: `uv run pytest tests/test_services.py -v`
Expected: PASS (new validation tests + all updated existing tests).
- [ ] **Step 5: Run quality gates**
Run: `uv run ruff format src tests && uv run ruff check src tests && uv run ty check`
Expected: all green.
- [ ] **Step 6: Commit**
```bash
git add src/hqt/projects/service.py tests/test_services.py
git commit -m "feat: validate project paths at create/update
Reject non-existent or non-directory paths with ServiceError (already
surfaced as a TUI notification), and store the resolved absolute path.
Closes the P2 finding where bad paths became dead sessions later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Task 2: Codex capture refuses ambiguous matches
**Files:**
- Modify: `src/hqt/harnesses/configurators/codex.py`
- Test: `tests/test_harnesses.py`
**Context:** `capture_session_id` scans `~/.codex/sessions/rollout-*.jsonl` for rollouts whose `cwd` matches the project and whose start time is `>= since`. Today it sorts the matches and returns the earliest. When two Codex sessions ran in the same directory in the capture window, that guess can be the wrong conversation. The fix: if more than one rollout matches, refuse to guess (return `None` + warn). The single-match normal case is unchanged.
- [ ] **Step 1: Replace the earliest-wins tests with an ambiguity test**
In `tests/test_harnesses.py`:
1. **Delete** `test_codex_capture_first_post_since_cwd_match_wins` (it asserts the now-removed earliest-wins behavior with two matching rollouts).
2. **Delete** `test_codex_capture_prefers_started_nearest_since_over_newest_file` (also asserts earliest-wins among two matching rollouts).
3. **Add** this test (place it where the deleted ones were):
```python
def test_codex_capture_ambiguous_returns_none(tmp_path):
"""Two rollouts match cwd + since window -> refuse to guess, return None."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout_a = sessions_dir / "rollout-a.jsonl"
rollout_b = sessions_dir / "rollout-b.jsonl"
_write_rollout(rollout_a, "id-a", "/projects/foo")
_write_rollout(rollout_b, "id-b", "/projects/foo")
now = time.time()
since = now - 120
os.utime(rollout_a, (now - 30, now - 30))
os.utime(rollout_b, (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 is None
```
The single-match regression cases (`test_codex_capture_session_id`,
`test_codex_capture_accepts_file_after_since`,
`test_codex_capture_skips_non_matching_cwd_picks_older_matching` — which has only
one cwd-matching rollout) stay as-is and must keep passing.
- [ ] **Step 2: Run the capture tests to verify the new one fails**
Run: `uv run pytest tests/test_harnesses.py -k "capture" -v`
Expected: `test_codex_capture_ambiguous_returns_none` FAILS (current code returns `"id-a"`, not `None`). The deleted tests are gone.
- [ ] **Step 3: Implement the ambiguity guard**
In `src/hqt/harnesses/configurators/codex.py`, add logging and change `capture_session_id`. Replace the imports block at the top:
```python
import json
import logging
from datetime import datetime
from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
log = logging.getLogger(__name__)
```
Replace the tail of `capture_session_id` (the `candidates.sort(...)` / return lines) with the ambiguity guard. The full method:
```python
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, str]] = []
for rollout in sessions_dir.rglob("rollout-*.jsonl"):
try:
mtime = rollout.stat().st_mtime
with rollout.open() as f:
meta = json.loads(f.readline())
payload = meta["payload"]
if meta.get("type") != "session_meta" or str(
Path(payload["cwd"])
) != str(project_path):
continue
started_at = self._meta_timestamp(payload) or mtime
if started_at >= since:
candidates.append((started_at, payload["id"]))
except (json.JSONDecodeError, KeyError, OSError, TypeError):
continue
if len(candidates) > 1:
# Ambiguous: more than one Codex rollout matches this cwd + time
# window (e.g. a second Codex started in the same project). Guessing
# risks storing the wrong conversation id, so keep the placeholder.
log.warning(
"capture_session_id: %d rollouts match cwd=%s since=%s; refusing to guess",
len(candidates),
project_path,
since,
)
return None
return candidates[0][1] if candidates else None
```
(The `candidates.sort(...)` line is removed — with at most one returned candidate there is nothing to sort.)
- [ ] **Step 4: Run the capture tests**
Run: `uv run pytest tests/test_harnesses.py -k "capture" -v`
Expected: PASS, including `test_codex_capture_ambiguous_returns_none`.
- [ ] **Step 5: Run quality gates**
Run: `uv run ruff format src tests && uv run ruff check src tests && uv run ty check`
Expected: all green.
- [ ] **Step 6: Commit**
```bash
git add src/hqt/harnesses/configurators/codex.py tests/test_harnesses.py
git commit -m "fix: refuse ambiguous Codex session-id capture
When more than one rollout matches the project cwd within the capture
window, return None and warn instead of guessing the earliest. The worst
case is now a kept placeholder id, never a wrong one. Part of the P2
capture-race fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Task 3: Serialize the spawn→capture window in SessionService
**Files:**
- Modify: `src/hqt/sessions/service.py`
- Test: `tests/test_sessions.py`
**Context:** The Task 2 guard makes wrong captures impossible, but two hqt-launched Codex sessions starting in the same project would now each see an ambiguous window and capture nothing. An `asyncio.Lock` held across the spawn→capture region (only for harnesses that `captures_session_id`) keeps hqt-launched starts from overlapping, so each capture sees exactly its own rollout.
- [ ] **Step 1: Write the failing tests**
Add to `tests/test_sessions.py`. The first asserts the lock is held while a capturing harness spawns+captures; the second asserts it is NOT held for a non-capturing harness (so we only pay the serialization cost when needed).
```python
@pytest.mark.asyncio
async def test_create_session_holds_capture_lock_for_capturing_harness(
factory, db, tmux
):
observed = {}
def _capture(project_path, since):
# capture runs inside the spawn->capture critical section
observed["locked_during_capture"] = service._capture_lock.locked()
return "real-codex-id"
h = MagicMock()
h.captures_session_id = True
h.generate_session_id.return_value = "placeholder"
h.build_spawn_config.return_value = MagicMock(
command=["codex"], env={}, cwd=Path("/tmp/myproj")
)
h.capture_session_id.side_effect = _capture
service = SessionService(factory=factory, tmux=tmux, harnesses={"claude-code": h})
result = await service.create_session(project_id=1, harness_name="claude-code")
assert observed["locked_during_capture"] is True
assert result.session.harness_session_id == "real-codex-id"
# Lock is released after create_session returns.
assert service._capture_lock.locked() is False
@pytest.mark.asyncio
async def test_create_session_no_lock_for_non_capturing_harness(service, db, tmux):
observed = {}
async def _spawn(req):
observed["locked_during_spawn"] = service._capture_lock.locked()
return SpawnResult(ok=True, window_id="@1", error="")
tmux.spawn.side_effect = _spawn
await service.create_session(project_id=1, harness_name="claude-code")
# The `service` fixture's harness has captures_session_id = False, so the
# spawn must NOT be serialized under the capture lock.
assert observed["locked_during_spawn"] is False
```
- [ ] **Step 2: Run the new tests to verify they fail**
Run: `uv run pytest tests/test_sessions.py -k "capture_lock or no_lock" -v`
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute '_capture_lock'`.
- [ ] **Step 3: Add the lock and the conditional guard helper**
In `src/hqt/sessions/service.py`:
Add `import contextlib` near the top imports (alongside `import asyncio`):
```python
import asyncio
import contextlib
import logging
import time
```
Add the lock in `__init__` and a helper context manager. Update `__init__`:
```python
def __init__(
self,
factory: sessionmaker,
tmux: TmuxManager,
harnesses: Mapping[str, HarnessConfigurator],
):
self.factory = factory
self.tmux = tmux
self.harnesses = harnesses
# Serializes the spawn->capture window for harnesses that capture a
# session id (codex), so two concurrent hqt starts in the same project
# never overlap and confuse capture_session_id.
self._capture_lock = asyncio.Lock()
@contextlib.asynccontextmanager
async def _maybe_capture_lock(self, needed: bool):
"""Hold the capture lock only when the harness captures a session id."""
if needed:
async with self._capture_lock:
yield
else:
yield
```
- [ ] **Step 4: Wrap the spawn→capture region in `create_session`**
In `create_session`, wrap from `since = time.time()` through the capture block in the guard. The relevant region becomes (note the added `async with` and the indentation of the lines it now encloses):
```python
spawn_cfg = configurator.build_spawn_config(
project_path, harness_session_id, model
)
log.info(
"Spawning window %s: cmd=%s cwd=%s",
sess.tmux_session_name,
spawn_cfg.command,
spawn_cfg.cwd,
)
async with self._maybe_capture_lock(configurator.captures_session_id):
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, project_path, since, sess.tmux_session_name
)
if captured_id:
sess.harness_session_id = captured_id
db.commit()
```
`spawn_result` is referenced after the `async with` block (in the `return CreateSessionResult(...)` that follows) — it remains in scope because `async with` does not introduce a new scope. Leave the rest of the method (the `log.info` and `return CreateSessionResult(...)`) unchanged.
- [ ] **Step 5: Wrap the rung-2 fresh-spawn region in `_respawn_with_fallback`**
In `_respawn_with_fallback`, wrap the rung-2 region (from `since = time.time()` through the capture block) in the same guard. The rung-2 portion becomes:
```python
# Rung 2: fresh spawn (codex ignores the old session id; start fresh)
configurator = self._configurator(sess.harness.name)
project_path = self._project_path(db, sess.project_id)
harness_session_id = (
sess.harness_session_id or configurator.generate_session_id(sess.id)
)
spawn_cfg = configurator.build_spawn_config(
project_path, harness_session_id, sess.model
)
async with self._maybe_capture_lock(configurator.captures_session_id):
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, project_path, since, window_name
)
if new_id:
sess.harness_session_id = new_id
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
```
The `return False` inside the `async with` correctly releases the lock on the way out. Leave rung 1 (the resume attempt above this region) unchanged — it does not spawn a new conversation, so it needs no lock.
- [ ] **Step 6: Run the session tests**
Run: `uv run pytest tests/test_sessions.py -v`
Expected: PASS (new lock tests + all existing session tests).
- [ ] **Step 7: Run quality gates**
Run: `uv run ruff format src tests && uv run ruff check src tests && uv run ty check`
Expected: all green.
- [ ] **Step 8: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "fix: serialize Codex spawn->capture with a lock
Hold an asyncio.Lock across the spawn->capture window for harnesses that
capture a session id, so two concurrent hqt starts in the same project
never produce overlapping capture windows. Completes the P2 capture-race
fix alongside the ambiguity guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Task 4: Real-tmux smoke tests
**Files:**
- Create: `tests/test_tmux_smoke.py`
- Modify: `pyproject.toml`
**Context:** tmux behavior is otherwise tested with mocked `_exec`. These tests drive the real `TmuxRunner` against a throwaway tmux server isolated via `TMUX_TMPDIR`, so they never touch the user's live sessions, and skip when tmux is absent.
- [ ] **Step 1: Register the `tmux` pytest marker**
In `pyproject.toml`, add a `[tool.pytest.ini_options]` block (none exists yet — add it after the `[dependency-groups]` block, before `[tool.hqt.quality]`):
```toml
[tool.pytest.ini_options]
markers = [
"tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server",
]
```
Note: the existing async tests already run with explicit `@pytest.mark.asyncio` decorators under pytest-asyncio's default strict mode, so do **not** add an `asyncio_mode` setting — only the `markers` entry is needed here.
- [ ] **Step 2: Write the smoke tests**
Create `tests/test_tmux_smoke.py`:
```python
"""Real-tmux smoke tests.
These drive the actual TmuxRunner against a throwaway tmux server whose socket
lives in a temp TMUX_TMPDIR, so they never touch the user's live tmux sessions.
They skip when no tmux binary is available.
"""
import asyncio
import os
import shutil
import subprocess
import pytest
from hqt.tmux.runner import TmuxRunner
pytestmark = [
pytest.mark.tmux,
pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"),
]
SESSION = "hqt-test"
def _tmux(env, *args):
"""Run a raw tmux command against the isolated server, return stdout."""
return subprocess.run(
["tmux", *args],
env=env,
capture_output=True,
text=True,
check=False,
)
@pytest.fixture
def tmux_env(tmp_path, monkeypatch):
# Point tmux's server socket at the temp dir: a fully isolated server.
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
env = {**os.environ, "TMUX_TMPDIR": str(tmp_path)}
_tmux(env, "new-session", "-d", "-s", SESSION, "-x", "200", "-y", "50")
try:
yield env
finally:
_tmux(env, "kill-server")
@pytest.fixture
def runner(tmux_env):
return TmuxRunner(tmux_path="tmux", session_name=SESSION)
async def _wait_pane_dead(runner, window, timeout=2.0):
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
if await runner.is_pane_dead(window):
return True
await asyncio.sleep(0.05)
return await runner.is_pane_dead(window)
@pytest.mark.asyncio
async def test_new_window_appears_in_list(runner, tmp_path):
window_id = await runner.new_window("hqt-1", str(tmp_path), "sleep 60")
assert window_id is not None
assert "hqt-1" in await runner.list_windows()
@pytest.mark.asyncio
async def test_set_window_label_round_trips(runner, tmp_path, tmux_env):
await runner.new_window("hqt-1", str(tmp_path), "sleep 60")
await runner.set_window_label("hqt-1", "•hqt-1")
shown = _tmux(
tmux_env,
"show-options",
"-w",
"-t",
f"{SESSION}:=hqt-1",
"@hqt_label",
).stdout
assert "•hqt-1" in shown
@pytest.mark.asyncio
async def test_respawn_revives_dead_pane(runner, tmp_path):
# Window whose command exits immediately -> pane dies (remain-on-exit keeps it).
await runner.new_window("hqt-1", str(tmp_path), "true")
assert await _wait_pane_dead(runner, "hqt-1") is True
assert await runner.respawn_pane("hqt-1", "sleep 60", str(tmp_path)) is True
alive, _ = await runner.verify_window_alive("hqt-1", timeout=1.0)
assert alive is True
@pytest.mark.asyncio
async def test_apply_theme_sets_session_options(runner, tmux_env):
await runner.apply_theme()
shown = _tmux(tmux_env, "show-options", "-t", SESSION, "status-justify").stdout
assert "left" in shown
```
- [ ] **Step 3: Run the smoke tests**
Run: `uv run pytest tests/test_tmux_smoke.py -v`
Expected: PASS where tmux is installed (4 tests). If tmux is absent: all SKIPPED with reason "tmux not installed".
- [ ] **Step 4: Verify the marker is registered (no warnings)**
Run: `uv run pytest tests/test_tmux_smoke.py -v -W error::pytest.PytestUnknownMarkWarning`
Expected: PASS/SKIP with no `PytestUnknownMarkWarning` (proves the marker is registered).
- [ ] **Step 5: Run the full suite + quality gates**
Run: `uv run pytest && uv run ruff format src tests && uv run ruff check src tests && uv run ty check`
Expected: full suite green (smoke tests pass or skip), gates green.
- [ ] **Step 6: Commit**
```bash
git add tests/test_tmux_smoke.py pyproject.toml
git commit -m "test: add isolated real-tmux smoke tests
Drive the real TmuxRunner against a throwaway tmux server isolated via
TMUX_TMPDIR (never touches live sessions), covering new windows, label
round-trip, respawn, and theming. Auto-skip when tmux is absent; register
the 'tmux' marker. Closes the P2 real-tmux testing gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Task 5: Remove resolved P2 findings from TODO.md
**Files:**
- Modify: `TODO.md`
**Context:** All three P2 findings are now resolved. `TODO.md` currently contains exactly those three lines and nothing else.
- [ ] **Step 1: Empty out the resolved findings**
`TODO.md` currently reads:
```
P2: Codex session-id capture is heuristic and can store the wrong rollout if another Codex session starts in the same project during the retry window.
P2: tmux behavior is mostly tested with mocks; add isolated real-tmux smoke tests for theming, new windows, respawn, and labels.
P2: Project paths are accepted without existence validation, so bad paths become dead sessions later instead of being rejected early.
```
Replace the entire file contents with a single placeholder line so the file is not left dangling:
```
No open findings.
```
- [ ] **Step 2: Verify**
Run: `cat TODO.md`
Expected: `No open findings.`
- [ ] **Step 3: Commit**
```bash
git add TODO.md
git commit -m "chore: clear resolved P2 findings from TODO
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Final verification (after all tasks)
- [ ] Run the full suite and gates once more:
```
uv run pytest && uv run ruff format src tests && uv run ruff check src tests && uv run ty check
```
Expected: all tests pass (or smoke tests skip without tmux), all gates green.
- [ ] Confirm `src/hqt/tmux/runner.py` is still only modified in the working tree (its pre-existing WIP) and was never committed:
```
git log --oneline -6
git status --short
```
Expected: the six new commits are present; `git status --short` shows only ` M src/hqt/tmux/runner.py`.
File diff suppressed because it is too large Load Diff
@@ -1,550 +0,0 @@
# 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.
@@ -1,961 +0,0 @@
# Tool Palette (nvim / lazygit / shell / clone) 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:** An `Alt+p` fuzzy palette (one tmux binding) that, for the current hqt session's project, opens nvim/lazygit/shell in a new hqt-styled tmux window, or `clone`s a fresh harness session with the same project + harness + model.
**Architecture:** A tool registry feeds a low-level `TmuxRunner.new_aux_window` (spawn + style + switch a non-session window, targeting by `window_id`, no `remain-on-exit`). `SessionService` resolves a tmux window name to a session and either opens a tool window or, for `clone`, reuses `create_session`. Two CLI subcommands bridge tmux to the service: `hqt palette <window>` (builds the fzf `display-popup`) and `hqt tool <name> <window>` (dispatches tool vs. clone). The binding is `bind -n M-p run-shell -b "hqt palette '#{window_name}'"``run-shell` format-expands `#{window_name}` (verified), `display-popup` does not (verified), so the resolved name is baked into the popup as a literal.
**Tech Stack:** Python 3, async/await, tmux CLI (3.6b), Textual, click, pytest + unittest.mock, fzf.
**Conventions:** TDD per task (test → see it fail → implement → see it pass → commit). All commit commands include the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` trailer. **Work happens on `main`** (the user asked to commit there directly). The design spec lives at `docs/superpowers/specs/2026-06-10-tool-windows-design.md`.
---
## File Structure
- **Create** `src/hqt/tools.py``Tool` dataclass + `TOOLS` registry. One job: name → aux spawn spec.
- **Modify** `src/hqt/tmux/runner.py` — add `new_aux_window` (+ `import shlex`).
- **Modify** `src/hqt/tmux/manager.py` — add `open_aux_window` delegate.
- **Modify** `src/hqt/sessions/service.py` — add `session_id_for_window`, `open_tool_window`, `open_tool_window_for_window`, `clone_session_for_window` (+ `import shutil`, `from hqt.tools import TOOLS`).
- **Modify** `src/hqt/cli.py``_build_session_service` helper, `hqt tool` and `hqt palette` subcommands.
- **Modify** `~/.tmux.conf` — one `M-p` palette binding (user-owned file; manual verify via `source-file`).
- **Tests:** `tests/test_tools.py` (new), `tests/test_tmux.py`, `tests/test_sessions.py`, `tests/test_cli.py` (new if absent).
No TUI changes: per the design decision, Alt+p (tmux) is the only trigger.
---
## Task 1: Tool registry
**Files:**
- Create: `src/hqt/tools.py`
- Test: `tests/test_tools.py`
- [ ] **Step 1: Write the failing test**
Create `tests/test_tools.py`:
```python
from hqt.tools import TOOLS, Tool
def test_registry_has_the_three_tools():
assert set(TOOLS) == {"nvim", "lazygit", "shell"}
def test_each_entry_is_a_tool():
assert all(isinstance(t, Tool) for t in TOOLS.values())
def test_nvim_and_lazygit_have_commands():
assert TOOLS["nvim"].command == ["nvim"]
assert TOOLS["lazygit"].command == ["lazygit"]
def test_shell_has_empty_command_meaning_default_shell():
assert TOOLS["shell"].command == []
def test_labels_are_the_bare_tool_names():
assert TOOLS["nvim"].label == "nvim"
assert TOOLS["lazygit"].label == "lazygit"
assert TOOLS["shell"].label == "shell"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_tools.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'hqt.tools'`.
- [ ] **Step 3: Write minimal implementation**
Create `src/hqt/tools.py`:
```python
"""Tools that can be opened in their own tmux window for a session's project.
A tool window is NOT an hqt session: hqt spawns and styles it, then forgets it.
It lives purely as a tmux window until the tool exits. (``clone`` is handled
separately in the service — it creates a real session, not an aux window.)
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class Tool:
"""How to open a tool in its own window.
``label`` is the base ``@hqt_label`` text (the project name is appended per
spawn). ``command`` is the argv to run; an empty list means "use tmux's
default shell" (a plain interactive shell).
"""
label: str
command: list[str]
TOOLS: dict[str, Tool] = {
"nvim": Tool(label="nvim", command=["nvim"]),
"lazygit": Tool(label="lazygit", command=["lazygit"]),
"shell": Tool(label="shell", command=[]),
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_tools.py -v`
Expected: PASS (5 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tools.py tests/test_tools.py
git commit -m "Add tool registry for tool windows" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: `TmuxRunner.new_aux_window`
**Files:**
- Modify: `src/hqt/tmux/runner.py` (add `import shlex` near the top; add the method after `new_window`)
- Test: `tests/test_tmux.py`
Background: `_next_window_index()` runs `list-windows -F '#{window_index}'` and returns `max(indices) + 1`. The `runner` fixture in `tests/test_tmux.py` uses `session_name="hqt-main"` and stubs `_exec` with an `AsyncMock`, so a side-effect queue drives each tmux call. `_window_theme_args(target)` builds the Frappé per-window `set-option -w -t <target> ...` argv (no leading/trailing `;`).
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_tmux.py`:
```python
@pytest.mark.asyncio
async def test_new_aux_window_spawns_styles_and_selects(runner):
runner._exec.side_effect = [
(0, "0\n", ""), # _next_window_index: indices [0] -> next index 1
(0, "@7\n", ""), # new-window -P -F '#{window_id}'
(0, "", ""), # set-option (automatic-rename + @hqt_label + theme)
(0, "", ""), # select-window
]
wid = await runner.new_aux_window("lazygit", "/proj", ["lazygit"], "lazygit · proj")
assert wid == "@7"
calls = runner._exec.call_args_list
# new-window: next free index, name, cwd, print window_id, then the command.
assert calls[1].args == (
"new-window", "-t", "hqt-main:1", "-n", "lazygit",
"-c", "/proj", "-P", "-F", "#{window_id}", "lazygit",
)
# post-creation options target the window_id and NEVER set remain-on-exit.
assert calls[2].args[:6] == (
"set-option", "-w", "-t", "@7", "automatic-rename", "off",
)
assert "@hqt_label" in calls[2].args
assert "lazygit · proj" in calls[2].args
assert "remain-on-exit" not in calls[2].args
# switch to it.
assert calls[3].args == ("select-window", "-t", "@7")
@pytest.mark.asyncio
async def test_new_aux_window_shell_omits_command_arg(runner):
runner._exec.side_effect = [
(0, "2\n", ""), # indices [2] -> next index 3
(0, "@9\n", ""),
(0, "", ""),
(0, "", ""),
]
wid = await runner.new_aux_window("shell", "/proj", [], "shell · proj")
assert wid == "@9"
calls = runner._exec.call_args_list
assert calls[1].args == (
"new-window", "-t", "hqt-main:3", "-n", "shell",
"-c", "/proj", "-P", "-F", "#{window_id}",
)
@pytest.mark.asyncio
async def test_new_aux_window_returns_none_when_new_window_fails(runner):
runner._exec.side_effect = [
(0, "0\n", ""), # next index
(1, "", "boom"), # new-window fails
]
wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p")
assert wid is None
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tmux.py -k new_aux_window -v`
Expected: FAIL with `AttributeError: 'TmuxRunner' object has no attribute 'new_aux_window'`.
- [ ] **Step 3: Write the implementation**
In `src/hqt/tmux/runner.py`, add `import shlex` to the top import block (it currently reads `import asyncio` / `import logging` / `from dataclasses import dataclass` — insert `import shlex` alphabetically after `import logging`).
Then add this method immediately after `new_window` (after its `return window_id`):
```python
async def new_aux_window(
self, name: str, cwd: str, command: list[str], label: str
) -> str | None:
"""Create an auxiliary (non-session) tool window and switch to it.
Appends at the next free index, then styles by window_id — not name — so
duplicate names (tool windows are spawned fresh every time) stay
unambiguous. Deliberately does NOT set remain-on-exit: the window closes
when the tool exits (a quit shell closes its window too). The window is
never tracked by hqt; it lives purely as a tmux window.
Returns the window_id, or None on failure (the half-created window is
cleaned up).
"""
idx = await self._next_window_index()
args = [
"new-window",
"-t",
f"{self.session_name}:{idx}",
"-n",
name,
"-c",
cwd,
"-P",
"-F",
"#{window_id}",
]
if command:
args.append(shlex.join(command))
rc, stdout, err = await self._exec(*args)
if rc != 0:
log.error("new-window (aux) failed: %s", err)
return None
window_id = stdout.strip()
# Style by window_id: automatic-rename off, the @hqt_label, then the
# Frappé per-window theme — one atomic invocation (";" argv separators).
rc, _, err = await self._exec(
"set-option",
"-w",
"-t",
window_id,
"automatic-rename",
"off",
";",
"set-option",
"-w",
"-t",
window_id,
"@hqt_label",
label,
";",
*_window_theme_args(window_id),
)
if rc != 0:
log.error("set-option (aux window) failed for %s: %s", name, err)
await self._exec("kill-window", "-t", window_id)
return None
await self._exec("select-window", "-t", window_id)
return window_id
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tmux.py -k new_aux_window -v`
Expected: PASS (3 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tmux/runner.py tests/test_tmux.py
git commit -m "Add TmuxRunner.new_aux_window for styled tool windows" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: `TmuxManager.open_aux_window`
**Files:**
- Modify: `src/hqt/tmux/manager.py` (add method to `TmuxManager`)
- Test: `tests/test_tmux.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_tmux.py`:
```python
@pytest.mark.asyncio
async def test_manager_open_aux_window_delegates(runner):
from unittest.mock import AsyncMock
runner.new_aux_window = AsyncMock(return_value="@4")
mgr = TmuxManager(runner)
wid = await mgr.open_aux_window("nvim", "/p", ["nvim"], "nvim · p")
assert wid == "@4"
runner.new_aux_window.assert_awaited_once_with("nvim", "/p", ["nvim"], "nvim · p")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_tmux.py -k manager_open_aux -v`
Expected: FAIL with `AttributeError: 'TmuxManager' object has no attribute 'open_aux_window'`.
- [ ] **Step 3: Write the implementation**
In `src/hqt/tmux/manager.py`, add this method to `TmuxManager` (e.g. after `set_window_label`):
```python
async def open_aux_window(
self, name: str, cwd: str, command: list[str], label: str
) -> str | None:
"""Open a styled tool window (non-session) and switch to it.
Returns the new window_id, or None on failure.
"""
return await self.runner.new_aux_window(name, cwd, command, label)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_tmux.py -k manager_open_aux -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tmux/manager.py tests/test_tmux.py
git commit -m "Add TmuxManager.open_aux_window delegate" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: `SessionService` tool-window methods
**Files:**
- Modify: `src/hqt/sessions/service.py` (add `import shutil`, `from hqt.tools import TOOLS`, three methods)
- Test: `tests/test_sessions.py`
Background: `ServiceError` comes from `hqt.errors`. The `tmux` fixture is `MagicMock(spec=TmuxManager)`, so `open_aux_window` is allowed once Task 3 added it; tests set it to an `AsyncMock`. The seeded project is `Project(name="myproj", path="/tmp/myproj")` at `project_id=1`; `create_session` makes window `hqt-1`. `Project`/`Session` and `selectinload`/`sessionmaker` are already imported in the service.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_sessions.py`:
```python
@pytest.mark.asyncio
async def test_open_tool_window_spawns_styled_window(service, db, tmux, monkeypatch):
await service.create_session(project_id=1, harness_name="claude-code")
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b)
tmux.open_aux_window = AsyncMock(return_value="@5")
wid = await service.open_tool_window(1, "lazygit")
assert wid == "@5"
tmux.open_aux_window.assert_awaited_once_with(
"lazygit", "/tmp/myproj", ["lazygit"], "lazygit · myproj"
)
@pytest.mark.asyncio
async def test_open_tool_window_shell_skips_which_check(service, db, tmux, monkeypatch):
await service.create_session(project_id=1, harness_name="claude-code")
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: None)
tmux.open_aux_window = AsyncMock(return_value="@3")
wid = await service.open_tool_window(1, "shell")
assert wid == "@3"
tmux.open_aux_window.assert_awaited_once_with(
"shell", "/tmp/myproj", [], "shell · myproj"
)
@pytest.mark.asyncio
async def test_open_tool_window_unknown_tool_raises(service):
with pytest.raises(ServiceError):
await service.open_tool_window(1, "emacs")
@pytest.mark.asyncio
async def test_open_tool_window_missing_binary_raises(service, db, monkeypatch):
await service.create_session(project_id=1, harness_name="claude-code")
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: None)
with pytest.raises(ServiceError):
await service.open_tool_window(1, "lazygit")
@pytest.mark.asyncio
async def test_open_tool_window_unknown_session_raises(service, monkeypatch):
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b)
with pytest.raises(ServiceError):
await service.open_tool_window(999, "nvim")
def test_session_id_for_window_resolves_and_misses(service, db):
import asyncio
asyncio.run(service.create_session(project_id=1, harness_name="claude-code"))
assert service.session_id_for_window("hqt-1") == 1
assert service.session_id_for_window("not-an-hqt-window") is None
@pytest.mark.asyncio
async def test_open_tool_window_for_window_resolves_by_name(
service, db, tmux, monkeypatch
):
await service.create_session(project_id=1, harness_name="claude-code")
monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b)
tmux.open_aux_window = AsyncMock(return_value="@2")
wid = await service.open_tool_window_for_window("hqt-1", "nvim")
assert wid == "@2"
tmux.open_aux_window.assert_awaited_once_with(
"nvim", "/tmp/myproj", ["nvim"], "nvim · myproj"
)
@pytest.mark.asyncio
async def test_open_tool_window_for_window_unknown_window_raises(service):
with pytest.raises(ServiceError):
await service.open_tool_window_for_window("not-an-hqt-window", "nvim")
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_sessions.py -k "tool_window or session_id_for_window" -v`
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'open_tool_window'`.
- [ ] **Step 3: Write the implementation**
In `src/hqt/sessions/service.py`: add `import shutil` with the other stdlib imports, and `from hqt.tools import TOOLS` with the other `hqt` imports.
Add these three methods to `SessionService` (e.g. after `attach_session`, before `_status_for`):
```python
def session_id_for_window(self, window_name: str) -> int | None:
"""Resolve a tmux window name to its active hqt session id, or None.
None means the window is not an hqt session window (a tool window, the
TUI home window, or an unrelated tmux window).
"""
with self.factory() as db:
sess = (
db.query(Session)
.filter_by(tmux_session_name=window_name, archived=False)
.first()
)
return sess.id if sess else None
async def open_tool_window(self, session_id: int, tool: str) -> str | None:
"""Open a tool (nvim/lazygit/shell) in a new styled window for a session.
Spawns at the next free index in the session's project directory and
switches to it. The window is NOT a tracked session. Raises ServiceError
for an unknown tool, a missing binary, or a missing session/project.
Returns the new window_id, or None if the tmux spawn fails.
"""
spec = TOOLS.get(tool)
if spec is None:
raise ServiceError(f"Unknown tool '{tool}'")
if spec.command and shutil.which(spec.command[0]) is None:
raise ServiceError(f"{spec.command[0]} not found on PATH")
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
raise ServiceError("Session not found")
project = db.get(Project, sess.project_id)
if project is None:
raise ServiceError("Project no longer exists")
cwd = project.path
label = f"{spec.label} · {project.name}"
return await self.tmux.open_aux_window(spec.label, cwd, spec.command, label)
async def open_tool_window_for_window(
self, window_name: str, tool: str
) -> str | None:
"""Open a tool window for the session identified by its tmux window name.
Used by the `hqt tool` CLI (the tmux binding passes #{window_name}).
Raises ServiceError if the name is not an active hqt session window.
"""
session_id = self.session_id_for_window(window_name)
if session_id is None:
raise ServiceError(f"{window_name!r} is not an hqt session window")
return await self.open_tool_window(session_id, tool)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_sessions.py -k "tool_window or session_id_for_window" -v`
Expected: PASS (8 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "Add SessionService tool-window + window-resolution methods" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 5: `SessionService.clone_session_for_window`
**Files:**
- Modify: `src/hqt/sessions/service.py` (add one method)
- Test: `tests/test_sessions.py`
Background: `create_session(project_id, harness_name, nickname, model)` returns a `CreateSessionResult` and spawns the harness window. clone reads the source session's project/harness/model and delegates. The seeded harness is `"claude-code"`; `selectinload` and `CreateSessionResult` are already in the module.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_sessions.py`:
```python
@pytest.mark.asyncio
async def test_clone_session_for_window_reuses_project_harness_model(
service, db, monkeypatch
):
await service.create_session(
project_id=1, harness_name="claude-code", nickname="orig", model="opus"
)
captured = {}
async def fake_create(project_id, harness_name, nickname=None, model=None):
captured["args"] = (project_id, harness_name, nickname, model)
return "SENTINEL"
monkeypatch.setattr(service, "create_session", fake_create)
result = await service.clone_session_for_window("hqt-1")
assert result == "SENTINEL"
# Same project + harness + model; a fresh sibling, so no nickname.
assert captured["args"] == (1, "claude-code", None, "opus")
@pytest.mark.asyncio
async def test_clone_session_for_window_unknown_window_raises(service):
with pytest.raises(ServiceError):
await service.clone_session_for_window("not-an-hqt-window")
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_sessions.py -k clone_session_for_window -v`
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'clone_session_for_window'`.
- [ ] **Step 3: Write the implementation**
In `src/hqt/sessions/service.py`, add this method to `SessionService` (next to the other tool methods from Task 4):
```python
async def clone_session_for_window(
self, window_name: str
) -> "CreateSessionResult":
"""Open a fresh harness session cloning the one in `window_name`.
Same project, harness, and model as the source session, but a brand-new
conversation (a new hqt-<id> window at the next index). Raises
ServiceError if `window_name` is not an active hqt session window — so
invoking clone from a tool window (nvim/shell) or the TUI is a clean
no-op.
"""
with self.factory() as db:
sess = (
db.query(Session)
.options(selectinload(Session.harness))
.filter_by(tmux_session_name=window_name, archived=False)
.first()
)
if sess is None:
raise ServiceError(f"{window_name!r} is not an hqt session window")
project_id = sess.project_id
harness_name = sess.harness.name
model = sess.model
return await self.create_session(
project_id, harness_name, nickname=None, model=model
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_sessions.py -k clone_session_for_window -v`
Expected: PASS (2 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "Add SessionService.clone_session_for_window" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 6: CLI `hqt tool` subcommand (tool + clone dispatch)
**Files:**
- Modify: `src/hqt/cli.py` (add `_build_session_service` helper + `tool` subcommand)
- Test: `tests/test_cli.py` (create if absent)
Background: existing subcommands (`doctor`, `list`) import their deps inside the function. `Settings` accepts `db_path=`. `ensure_db` creates the sqlite file. The helper mirrors `HqtApp.on_mount`'s wiring.
- [ ] **Step 1: Write the failing tests**
Create (or append to) `tests/test_cli.py`:
```python
import pytest
from click.testing import CliRunner
from hqt import cli
from hqt.config import Settings
def test_tool_cmd_opens_tool_window(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
calls = {}
async def fake_for_window(self, window, tool):
calls["tool"] = (window, tool)
return "@9"
monkeypatch.setattr(
"hqt.sessions.service.SessionService.open_tool_window_for_window",
fake_for_window,
)
result = CliRunner().invoke(cli.main, ["tool", "lazygit", "hqt-5"])
assert result.exit_code == 0, result.output
assert calls["tool"] == ("hqt-5", "lazygit")
def test_tool_cmd_clone_dispatches_to_clone(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
calls = {}
async def fake_clone(self, window):
calls["clone"] = window
return "RESULT"
monkeypatch.setattr(
"hqt.sessions.service.SessionService.clone_session_for_window", fake_clone
)
result = CliRunner().invoke(cli.main, ["tool", "clone", "hqt-5"])
assert result.exit_code == 0, result.output
assert calls["clone"] == "hqt-5"
def test_tool_cmd_reports_service_error(monkeypatch, tmp_path):
from hqt.errors import ServiceError
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
async def boom(self, window, tool):
raise ServiceError("'hqt-5' is not an hqt session window")
monkeypatch.setattr(
"hqt.sessions.service.SessionService.open_tool_window_for_window", boom
)
result = CliRunner().invoke(cli.main, ["tool", "nvim", "hqt-5"])
assert result.exit_code == 1
assert "not an hqt session window" in result.output
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_cli.py -k tool_cmd -v`
Expected: FAIL — `tool` is not a command (`Error: No such command 'tool'`), so exit_code != 0.
- [ ] **Step 3: Write the implementation**
In `src/hqt/cli.py`, add a module-level helper and the `tool` command after `list_cmd`:
```python
def _build_session_service():
"""Wire a SessionService the same way HqtApp.on_mount does (for CLI use)."""
from hqt.config import get_settings
from hqt.db.engine import ensure_db, get_engine, get_session_factory
from hqt.harnesses.registry import discover_harnesses
from hqt.sessions.service import SessionService
from hqt.tmux.manager import TmuxManager
from hqt.tmux.runner import TmuxRunner
settings = get_settings()
ensure_db(settings)
factory = get_session_factory(get_engine(settings))
runner = TmuxRunner(settings.tmux_path, settings.tui_session_name)
return SessionService(factory, TmuxManager(runner), discover_harnesses())
@main.command(name="tool")
@click.argument("tool")
@click.argument("window")
def tool_cmd(tool, window):
"""Run TOOL for the session in tmux WINDOW.
TOOL is nvim/lazygit/shell (opens a styled tool window) or "clone" (a fresh
harness with the same project + model). WINDOW is the tmux window name (the
hqt-<id> key, e.g. from #{window_name}).
"""
import asyncio
from hqt.errors import ServiceError
svc = _build_session_service()
try:
if tool == "clone":
asyncio.run(svc.clone_session_for_window(window))
else:
asyncio.run(svc.open_tool_window_for_window(window, tool))
except ServiceError as err:
click.echo(str(err), err=True)
raise SystemExit(1)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_cli.py -k tool_cmd -v`
Expected: PASS (3 passed).
Note: `CliRunner` mixes stderr into `result.output`, so the `click.echo(..., err=True)` message is asserted via `result.output`.
- [ ] **Step 5: Commit**
```bash
git add src/hqt/cli.py tests/test_cli.py
git commit -m "Add hqt tool CLI subcommand (tool + clone dispatch)" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 7: CLI `hqt palette` subcommand
**Files:**
- Modify: `src/hqt/cli.py` (add palette helpers + `palette` subcommand)
- Test: `tests/test_cli.py`
Background: `hqt palette <window>` is what the `M-p` binding invokes (via `run-shell`, which has already expanded `#{window_name}` to a concrete name). It pre-checks the window: a non-session window gets a one-line tmux message; a session window gets the fzf `display-popup`, whose selection runs `hqt tool <choice> <window>`. `display-popup` does NOT expand formats, so the window name is baked in as a `shlex.quote`d literal. Both branches call `tmux` via `subprocess.run`, which the test monkeypatches.
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_cli.py`:
```python
def test_palette_cmd_shows_popup_for_session_window(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.session_id_for_window",
lambda self, window: 5,
)
runs = []
monkeypatch.setattr(cli.subprocess, "run", lambda argv, **kw: runs.append(argv))
result = CliRunner().invoke(cli.main, ["palette", "hqt-5"])
assert result.exit_code == 0, result.output
assert len(runs) == 1
argv = runs[0]
assert "display-popup" in argv
popup_cmd = argv[-1]
assert "fzf" in popup_cmd
assert "nvim" in popup_cmd and "clone" in popup_cmd
# the window is baked into the command for `hqt tool {} <window>`
assert "hqt-5" in popup_cmd
def test_palette_cmd_hints_for_non_session_window(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.session_id_for_window",
lambda self, window: None,
)
runs = []
monkeypatch.setattr(cli.subprocess, "run", lambda argv, **kw: runs.append(argv))
result = CliRunner().invoke(cli.main, ["palette", "nvim"])
assert result.exit_code == 0, result.output
assert len(runs) == 1
argv = runs[0]
assert "display-message" in argv
assert "display-popup" not in argv
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_cli.py -k palette_cmd -v`
Expected: FAIL — `palette` is not a command, OR `AttributeError: module 'hqt.cli' has no attribute 'subprocess'` if `subprocess` is not imported at module level yet (it is — `cli.py` already `import subprocess`).
- [ ] **Step 3: Write the implementation**
In `src/hqt/cli.py`, add the palette helpers and command after `tool_cmd` (`subprocess` is already imported at the top of the module):
```python
PALETTE_ENTRIES = ["nvim", "lazygit", "shell", "clone"]
# fzf colors matching the Catppuccin Frappé status bar / the Alt+o switcher.
_PALETTE_FZF_COLORS = (
"bg:#292c3c,bg+:#414559,fg:#c6d0f5,fg+:#c6d0f5,hl:#ef9f76,hl+:#ef9f76,"
"pointer:#ef9f76,prompt:#8caaee,info:#838ba7,border:#838ba7"
)
def _palette_popup_command(window: str) -> str:
"""Shell pipeline for the fzf popup; `window` is baked in as a literal.
display-popup does NOT format-expand its command, so the window name must be
concrete here (run-shell already expanded #{window_name} before `hqt palette`
ran). The selected entry runs `hqt tool <choice> <window>`.
"""
import shlex
entries = "\\n".join(PALETTE_ENTRIES) + "\\n"
return (
f"printf '{entries}' | "
f"fzf --reverse --no-info --prompt='tool ' --pointer='' "
f"--color='{_PALETTE_FZF_COLORS}' | "
f"xargs -r -I{{}} hqt tool {{}} {shlex.quote(window)}"
)
@main.command(name="palette")
@click.argument("window")
def palette_cmd(window):
"""Pop an fzf tool palette for the session in tmux WINDOW (bound to M-p)."""
from hqt.config import get_settings
settings = get_settings()
svc = _build_session_service()
if svc.session_id_for_window(window) is None:
subprocess.run(
[
settings.tmux_path,
"display-message",
"hqt: open the tool palette from a harness window",
]
)
return
subprocess.run(
[
settings.tmux_path,
"display-popup",
"-E",
"-w",
"40%",
"-h",
"30%",
"-T",
" open tool ",
"-S",
"fg=#838ba7",
_palette_popup_command(window),
]
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_cli.py -k palette_cmd -v`
Expected: PASS (2 passed).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/cli.py tests/test_cli.py
git commit -m "Add hqt palette CLI subcommand (fzf tool launcher)" \
-m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: tmux keybinding (`~/.tmux.conf`)
**Files:**
- Modify: `~/.tmux.conf` (user-owned; append one binding)
This file is the user's own keybindings file (global tmux bindings are inherently server-wide and live here deliberately). No automated test — verify by re-sourcing.
- [ ] **Step 1: Append the binding**
Add to the end of `~/.tmux.conf` (near the existing `Alt+o` switcher):
```tmux
# Tool palette: Alt+p pops an fzf launcher for the CURRENT hqt session's project
# (works inside a harness). Pick nvim / lazygit / shell to open a styled window at
# the next index, or "clone" for a fresh harness with the same project+model.
# run-shell expands #{window_name} (the hqt-<id> key) and hands it to `hqt palette`,
# which builds the popup — display-popup does NOT expand formats, so the name is
# resolved here. From a non-session window it shows a brief hint. -b keeps the tmux
# server responsive during hqt's ~0.3-0.6s startup.
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
```
- [ ] **Step 2: Re-source and verify the binding registered**
Run:
```bash
tmux source-file ~/.tmux.conf && echo "sourced OK"
tmux list-keys -T root | grep -E "M-p\b"
```
Expected: `sourced OK`, then a line showing `run-shell -b "hqt palette ..."` bound to `M-p`.
- [ ] **Step 3: Manual smoke test**
Confirm `hqt` is on `PATH` (`command -v hqt`) and `fzf` is installed
(`command -v fzf`). From inside a harness pane (an `hqt-<id>` window) press
`M-p`:
- the fzf popup lists nvim / lazygit / shell / clone;
- pick `lazygit` → a styled lazygit window appears at the next index and closes on
quit; repeat for `nvim` and `shell`;
- pick `clone` → a fresh `hqt-<id>` harness window appears (same project + model)
and shows up in the TUI session list within ~3s.
Then press `M-p` from the TUI home window (or a tool window) → a brief
"open the tool palette from a harness window" message, no menu. (No commit —
`~/.tmux.conf` is outside the repo.)
---
## Final verification
- [ ] **Run the whole suite**
Run: `uv run pytest -q`
Expected: all green (existing tests unaffected; new tests pass).
- [ ] **Lint/type check (match the project's quality gates)**
Run: `uv run ruff check . && uv run ty check` (or the project's configured gates).
Expected: clean. Fix any issues, then amend/commit.
---
## Self-Review (completed during authoring)
- **Spec coverage:** registry (Task 1); `new_aux_window` styling / no-remain-on-exit / window_id targeting / append-right (Task 2); manager delegate (Task 3); `session_id_for_window` + `open_tool_window` + `_for_window` resolution + which-check (Task 4); `clone_session_for_window` reuse of project/harness/model + no-op guard (Task 5); `hqt tool` clone-vs-tool dispatch + error mapping (Task 6); `hqt palette` popup-vs-hint (Task 7); `M-p` binding via run-shell bridge (Task 8). Every spec section maps to a task.
- **Type consistency:** `new_aux_window(name, cwd, command, label)` / `open_aux_window(name, cwd, command, label)` signatures match across runner/manager/service/tests; the service calls them with `name=spec.label`. `session_id_for_window` is sync and reused by `open_tool_window_for_window`. `clone_session_for_window` returns `CreateSessionResult` (the same type `create_session` returns). `ServiceError` imported from `hqt.errors` everywhere. The CLI `tool`/`palette` commands and `_build_session_service` use the verified wiring.
- **Verified tmux facts (tmux 3.6b):** `run-shell` expands `#{window_name}`; `display-popup` and its `-e` value do NOT; `display-message -p` inside a popup is client-ambiguous — hence the `run-shell → hqt palette → display-popup (literal window)` bridge.
- **No placeholders:** every code/test step contains complete code; every run step has an exact command and expected result.
@@ -1,216 +0,0 @@
# Worktree-Isolated Sessions — Implementation Plan
Spec: docs/superpowers/specs/2026-06-10-worktree-sessions-design.md
Branch: worktree-sessions
Quality gates after every task (must pass before commit):
```
uv run ruff format src tests
uv run ruff check src tests
uv run ty check
uv run pytest -q
```
Do NOT stage `src/hqt/tmux/runner.py` — it carries an unrelated uncommitted
user change. Stage only files you created/modified for your task.
## Task 1 — Git worktree module
Create `src/hqt/git/__init__.py` and `src/hqt/git/worktree.py`, plus
`tests/test_worktree.py`.
Module contents (async functions use `asyncio.create_subprocess_exec`, in the
style of `src/hqt/tmux/runner.py`; failures raise `hqt.errors.ServiceError`
with git's stderr included):
- `slugify(text: str) -> str` — lowercase; keep `[a-z0-9]`; collapse runs of
anything else into single `-`; strip leading/trailing `-`. Pure function.
- `async is_git_repo(path: Path) -> bool` — `git rev-parse
--is-inside-work-tree` in `path`; False on any failure (including missing
directory).
- `WorktreeState` dataclass: `exists: bool`, `dirty: bool`,
`unique_commits: int`.
- `async create_worktree(repo: Path, branch: str) -> Path`:
1. Validate with `git check-ref-format --branch <branch>` → ServiceError
"Invalid branch name: ..." on failure.
2. Resolve git common dir (`git rev-parse --git-common-dir`, relative
results resolved against `repo`); append line `.worktrees/` to
`<common-dir>/info/exclude` if not already present (create file if
missing).
3. `git worktree add -b <branch> <repo>/.worktrees/<branch>` (cwd=repo).
ServiceError with stderr on failure (branch exists, dir exists, etc.).
4. Return the absolute worktree path.
- `async add_worktree_for_branch(repo: Path, branch: str) -> Path` —
`git worktree add <repo>/.worktrees/<branch> <branch>` (no `-b`), same
exclude handling, for recreating a vanished worktree.
- `async worktree_state(repo: Path, path: Path, branch: str) ->
WorktreeState` — `exists` = path exists on disk; `dirty` = non-empty
`git status --porcelain` run in the worktree (False if not exists);
`unique_commits` = line count of `git rev-list <branch> --not --all
--count`-style query. Use `git rev-list --count <branch> --not
--exclude=refs/heads/<branch> --all`... simplest correct form:
`git for-each-ref --format='%(refname)' refs/heads refs/remotes`, drop the
branch's own ref, then `git rev-list --count <branch> --not <others...>`
(0 when no other refs exist is acceptable only if branch tip is reachable
from them; with no other refs, count commits on branch). Implement and test
against a real repo; exact plumbing is the implementer's choice as long as
the tests below pass.
- `async remove_worktree(repo: Path, path: Path, branch: str, force: bool)
-> None` — `git worktree remove [--force] <path>` (ServiceError on
failure), then best-effort `git branch -d <branch>` (ignore failure), then
`git worktree prune`.
Tests (real git in `tmp_path` repos; configure user.name/email; create an
initial commit):
- slugify: mixed case, spaces, punctuation, leading/trailing junk, empty.
- is_git_repo: repo → True; plain dir → False; missing dir → False.
- create_worktree: creates `.worktrees/<branch>` with branch checked out;
`.git/info/exclude` contains `.worktrees/`; calling again with same branch
raises ServiceError; invalid branch name (`foo..bar`) raises ServiceError;
exclude not duplicated after two different worktrees.
- worktree_state: fresh worktree → exists, not dirty, 0 unique commits;
touch a file → dirty; commit on the branch → unique_commits == 1; after
removing dir manually → exists False.
- remove_worktree: clean worktree removed, branch (merged/no unique commits)
deleted; dirty worktree without force → ServiceError and dir remains; with
force → removed; unmerged branch survives removal (`git branch -d` fails
silently, branch still listed).
Commit message: `feat: git worktree module`.
## Task 2 — Session columns + migration
Modify `src/hqt/db/models.py` and `src/hqt/db/migrations.py`; extend
`tests/test_db.py`.
- `Session` gains `worktree_path: Mapped[str | None] =
mapped_column(default=None)` and `worktree_branch: Mapped[str | None] =
mapped_column(default=None)`.
- Add migration entry `(2, ...)` to `MIGRATIONS` executing two
`ALTER TABLE sessions ADD COLUMN` statements (worktree_path TEXT,
worktree_branch TEXT). `LATEST_VERSION` derives automatically.
- Tests: fresh DB has the columns and user_version 2; a DB created at
baseline (simulate: create tables via models without the new columns is
impractical — instead create a v1 DB by issuing the old CREATE TABLE
through exec_driver_sql or by building metadata then dropping the two
columns is messy; acceptable approach: stamp an existing schema to
user_version 1 after removing the columns via SQLite `ALTER TABLE ... DROP
COLUMN`, then run `migrate()` and assert columns exist and version == 2).
Follow the existing migration-test patterns in `tests/test_db.py` if any.
Commit message: `feat: worktree columns on sessions + migration v2`.
## Task 3 — SessionService worktree support
Modify `src/hqt/sessions/service.py`; extend `tests/test_sessions.py` /
`tests/test_services.py` (match where SessionService is currently tested).
- Import the worktree module as a module (`from hqt.git import worktree`)
so tests can monkeypatch `worktree.create_worktree` etc.
- `create_session(self, project_id, harness_name, nickname=None, model=None,
worktree_branch=None)`:
- When `worktree_branch` is falsy → behavior identical to today.
- When set: after resolving `project_path`, `await
worktree.create_worktree(project_path, worktree_branch)` BEFORE adding
the Session row; on ServiceError let it propagate (no row, no spawn).
Store `worktree_path=str(path)` and `worktree_branch` on the row. Spawn
config must be built with the worktree path, and the capture-retry loop
must also use the worktree path.
- Default nickname: if `worktree_branch` set and nickname blank, nickname
becomes the branch name.
- Add `_session_path(self, db, sess) -> Path`: `Path(sess.worktree_path)`
when set, else `self._project_path(db, sess.project_id)`. Use it in
`_respawn_with_fallback`, `_get_resume_config`, and
`_maybe_capture_missing_session_id` (replace direct project-path lookups
for harness-facing paths; `_maybe_capture_missing_session_id` keeps its
"project deleted → skip" behavior for non-worktree sessions, and for
worktree sessions uses the worktree path without needing the project row).
- `attach_session` rung 0: before the alive check... no — keep order: if
session has `worktree_path` and the path does not exist on disk:
- branch exists (`git rev-parse --verify refs/heads/<branch>` helper in
worktree module is NOT needed; use `worktree.add_worktree_for_branch`
and catch ServiceError) → recreate, then continue normal flow.
- recreate fails → raise ServiceError("Worktree and branch are gone;
delete the session").
- `delete_session(self, session_id, remove_worktree=False, force=False)`:
- Kill tmux window as today.
- If `remove_worktree` and session has worktree: `await
worktree.remove_worktree(project_path, Path(sess.worktree_path),
sess.worktree_branch, force)`. On ServiceError: do NOT delete the DB row;
re-raise (the TUI shows the notification; the tmux window being already
killed is acceptable).
- Then delete row + commit.
- Expose `async worktree_state_for(self, session_id) -> WorktreeState | None`
returning None for non-worktree sessions; the TUI uses it to build the
confirm dialog.
Tests (fake tmux pattern as existing tests; monkeypatch worktree functions
with AsyncMock/recording fakes):
- create_session with worktree_branch: create_worktree called with project
path and branch; spawn cwd == worktree path; capture uses worktree path;
row has worktree fields; nickname defaults to branch.
- create_worktree raising → ServiceError propagates, no Session row exists,
no spawn attempted.
- attach with worktree_path missing on disk → add_worktree_for_branch
called; on its failure → ServiceError with the delete-hint message.
- resume config for a worktree session uses worktree path.
- delete_session remove_worktree=True calls worktree.remove_worktree and
deletes row; worktree.remove_worktree raising → row still present.
- worktree_state_for: None for plain sessions; delegates for worktree ones.
Commit message: `feat: worktree-aware session lifecycle in SessionService`.
## Task 4 — TUI wiring + docs
Modify `src/hqt/tui/screens/new_session.py`, add
`src/hqt/tui/screens/confirm_delete.py`, modify `src/hqt/tui/app.py`,
`src/hqt/tui/widgets/session_list.py`, `src/hqt/status.py` callers if
needed, `README.md`; extend `tests/test_tui.py`.
- `NewSessionScreen(harness_names, is_git_repo)`:
- Result type becomes `tuple[str, str, str | None, str | None] | None`
(harness, nickname, model, worktree_branch).
- Add `Checkbox("Isolate in worktree", id="worktree-checkbox")` (textual
`Checkbox`); when `is_git_repo` is False, `disabled=True` and label
"Isolate in worktree (not a git repo)".
- Branch `Input(id="branch-input")` hidden (`display = False`) until the
checkbox is checked; when first revealed, prefill with
`slugify(nickname)` if the field is empty; user edits freely afterwards.
- On submit with checkbox checked: empty branch → block submit and focus
the branch field (or fall back to slugified nickname if non-empty;
if both empty, keep dialog open). Unchecked → worktree_branch None.
- `ConfirmDeleteScreen(ModalScreen[tuple[bool, bool] | None])` in new file:
constructor takes `session_label: str`, `warning: str | None`. Shows
"Delete session <label>?", optional warning line, `Checkbox("Also remove
worktree", value=warning is None)`, Delete/Cancel buttons. Returns
`(confirmed, remove_worktree)`; `remove_worktree and warning is not None`
means force.
- `app.py`:
- `action_new_session`: compute `is_git_repo` via
`await worktree.is_git_repo(Path(project.path))` (inside the worker or
pre-fetched; ProjectService has the path — follow existing access
patterns), pass to screen; pass `worktree_branch` to `create_session`.
- `action_delete_session`: fetch session; if it has no worktree, keep
today's immediate delete. Otherwise call
`sessions.worktree_state_for(sid)`, build warning string when dirty or
unique_commits > 0 ("N uncommitted file(s), M unmerged commit(s) — work
will be lost"), push ConfirmDeleteScreen; on (True, remove) call
`delete_session(sid, remove_worktree=remove, force=remove and warned)`.
ServiceError already surfaces via `_run_service_worker`.
- Session list: rows for worktree sessions append ` ⎇ <branch>`; check
`src/hqt/tui/widgets/session_list.py` for the row-label construction.
- tmux window label: in `sync_window_labels`, the name passed to
`window_label` for worktree sessions is `nickname or branch` (nickname
already defaults to branch at creation, so no change may be needed —
verify and leave a test).
- README: document the worktree checkbox under Key Bindings/feature notes,
and the `.worktrees/` location + submodule limitation.
- Tests: NewSessionScreen returns worktree_branch when checked, None when
unchecked, disabled checkbox when not a repo; ConfirmDeleteScreen returns
the right tuples; app delete path for non-worktree sessions unchanged
(no modal).
Commit message: `feat: worktree session UI (dialog checkbox, confirm delete, list marker)`.
@@ -0,0 +1,747 @@
# TUI Column Navigation & Session Archiving 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:** Add vim-style `h`/`l` focus movement between the Projects and Sessions columns, and a recoverable "archive" for sessions (hide-and-tear-down without deleting) with a per-project view toggle.
**Architecture:** Two independent slices. (1) Navigation: two app-level bindings that focus a specific list widget by id. (2) Archiving: the `archived` column already exists and read paths already filter it, so the work is a parameterized read (`list_sessions(archived=...)`), two new service mutators (`archive_session`/`unarchive_session`), and the TUI actions/keys/toggle that drive them. Archive is non-destructive (keeps the DB row + `harness_session_id` + worktree; only kills the resumable tmux window), so it is instant — no confirmation dialog.
**Tech Stack:** Python 3.14, async/await, SQLAlchemy ORM, Textual (App/Widget/Binding/ListView), tmux CLI, pytest + pytest-asyncio + unittest.mock. Runner: `uv run pytest`. Gates: `uv run ruff check .` and `uv run ty check`.
**Reference spec:** `docs/superpowers/specs/2026-06-11-tui-nav-archiving-design.md`
---
## File Structure
- `src/hqt/sessions/service.py` — add `archived` param to `list_sessions`; add `archive_session` and `unarchive_session`.
- `src/hqt/tui/widgets/session_list.py` — add `set_mode(archived)` to swap the "Sessions" / "Sessions (archived)" header.
- `src/hqt/tui/app.py` — new bindings (`h`, `l`, `x`, `A`, `u`) and actions; `_show_archived` flag; thread the flag through `_refresh_sessions`; guard `_poll_sessions`; gate `x`/`u` in `check_action`.
- `tests/test_sessions.py` — service-layer tests.
- `tests/test_tui.py` — TUI binding/action/toggle/gating tests.
No DB migration: the `archived` column exists at baseline schema v1.
---
## Task 1: Service — `list_sessions` archived filter
**Files:**
- Modify: `src/hqt/sessions/service.py:883-911` (`list_sessions`)
- Test: `tests/test_sessions.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_sessions.py`:
```python
@pytest.mark.asyncio
async def test_list_sessions_archived_filter(service, db, tmux):
h = db.query(Harness).filter_by(name="claude-code").first()
active = Session(
project_id=1, harness_id=h.id, tmux_session_name="hqt-active", archived=False
)
arch = Session(
project_id=1, harness_id=h.id, tmux_session_name="hqt-arch", archived=True
)
db.add_all([active, arch])
db.commit()
active_id, arch_id = active.id, arch.id
default = await service.list_sessions(project_id=1)
assert [i.session.id for i in default] == [active_id]
archived = await service.list_sessions(project_id=1, archived=True)
assert [i.session.id for i in archived] == [arch_id]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_sessions.py::test_list_sessions_archived_filter -v`
Expected: FAIL with `TypeError: list_sessions() got an unexpected keyword argument 'archived'`
- [ ] **Step 3: Add the parameter**
In `src/hqt/sessions/service.py`, change the `list_sessions` signature and filter. Keep `now` as the last parameter so existing positional callers are unaffected:
```python
async def list_sessions(
self, project_id: int, archived: bool = False, now: float | None = None
) -> list[SessionInfo]:
if now is None:
now = time.time()
with self.factory() as db:
sessions = (
db.query(Session)
.options(selectinload(Session.harness))
.filter_by(project_id=project_id, archived=archived)
.all()
)
```
(The rest of the method body is unchanged.)
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_sessions.py::test_list_sessions_archived_filter -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "feat: parameterize list_sessions with archived filter"
```
---
## Task 2: Service — `archive_session` and `unarchive_session`
**Files:**
- Modify: `src/hqt/sessions/service.py` (add two methods after `delete_session`, around line 827)
- Test: `tests/test_sessions.py`
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_sessions.py`:
```python
@pytest.mark.asyncio
async def test_archive_session_kills_window_and_keeps_row(service, db, tmux):
await service.create_session(project_id=1, harness_name="claude-code")
await service.archive_session(1)
tmux.kill.assert_called_once_with("hqt-1")
db.expire_all()
row = db.get(Session, 1)
assert row is not None
assert row.archived is True
# Excluded from the default (active) listing.
assert await service.list_sessions(project_id=1) == []
@pytest.mark.asyncio
async def test_archive_session_no_kill_when_window_missing(service, db, tmux):
await service.create_session(project_id=1, harness_name="claude-code")
tmux.window_exists = AsyncMock(return_value=False)
tmux.kill.reset_mock()
await service.archive_session(1)
tmux.kill.assert_not_called()
db.expire_all()
assert db.get(Session, 1).archived is True
@pytest.mark.asyncio
async def test_unarchive_session_restores_to_active_list(service, db, tmux):
await service.create_session(project_id=1, harness_name="claude-code")
await service.archive_session(1)
await service.unarchive_session(1)
db.expire_all()
assert db.get(Session, 1).archived is False
active = await service.list_sessions(project_id=1)
assert [i.session.id for i in active] == [1]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_sessions.py -k "archive_session or unarchive_session" -v`
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'archive_session'`
- [ ] **Step 3: Implement both methods**
In `src/hqt/sessions/service.py`, immediately after the `delete_session` method (after line 827), add:
```python
async def archive_session(self, session_id: int) -> None:
"""Archive a session: kill its tmux window but keep the DB row.
A recoverable "remove from view" — the window/harness process is torn
down (freeing resources) but the row, ``harness_session_id``, and any
worktree are preserved so the session can be restored or referenced
later. No-op if the session does not exist.
"""
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
return
if await self.tmux.window_exists(sess.tmux_session_name):
await self.tmux.kill(sess.tmux_session_name)
sess.archived = True
db.commit()
async def unarchive_session(self, session_id: int) -> None:
"""Restore an archived session by clearing its archived flag.
The tmux window stays gone (the session reads as dead until resumed);
this only makes the row visible in the active list again. No-op if the
session does not exist.
"""
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
return
sess.archived = False
db.commit()
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_sessions.py -k "archive_session or unarchive_session" -v`
Expected: PASS (3 tests)
- [ ] **Step 5: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "feat: add archive_session and unarchive_session to SessionService"
```
---
## Task 3: TUI — `h`/`l` column focus navigation
**Files:**
- Modify: `src/hqt/tui/app.py:67-79` (BINDINGS) and add two actions near `action_toggle_focus` (line 213)
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_tui.py`:
```python
@pytest.mark.asyncio
async def test_hl_focus_bindings_exist():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert bindings["h"].action == "focus_projects"
assert bindings["l"].action == "focus_sessions"
@pytest.mark.asyncio
async def test_h_focuses_projects_l_focuses_sessions(tmp_path):
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
d = tmp_path / "hl-proj"
d.mkdir()
app._project_service.create("hl", str(d))
app._load_projects()
await app.workers.wait_for_complete()
await pilot.pause()
await pilot.press("l")
await pilot.pause()
assert app.focused is app.query_one("#session-list")
await pilot.press("h")
await pilot.pause()
assert app.focused is app.query_one("#project-list")
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tui.py -k "hl_focus_bindings or h_focuses_projects" -v`
Expected: FAIL — `KeyError: 'h'` (binding test) and focus assertion error (action test)
- [ ] **Step 3: Add bindings and actions**
In `src/hqt/tui/app.py`, add two bindings to the `BINDINGS` list (after the `tab` binding on line 72):
```python
Binding("tab", "toggle_focus", "Switch Panel"),
Binding("h", "focus_projects", "Projects"),
Binding("l", "focus_sessions", "Sessions"),
```
Then add two actions immediately after `action_toggle_focus` (after line 214):
```python
def action_focus_projects(self) -> None:
self.query_one("#project-list").focus()
def action_focus_sessions(self) -> None:
self.query_one("#session-list").focus()
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tui.py -k "hl_focus_bindings or h_focuses_projects" -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "feat: h/l focus navigation between project and session columns"
```
---
## Task 4: Widget — `SessionList.set_mode` header toggle
**Files:**
- Modify: `src/hqt/tui/widgets/session_list.py:70-73` (add a method to `SessionList`)
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_tui.py`:
```python
@pytest.mark.asyncio
async def test_session_list_header_reflects_archived_mode():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.session_list import SessionList
from textual.widgets import Label
sl = app.query_one(SessionList)
header = sl.query_one("#session-header", Label)
assert str(header.render()) == "Sessions"
sl.set_mode(archived=True)
await pilot.pause()
assert "archived" in str(header.render()).lower()
sl.set_mode(archived=False)
await pilot.pause()
assert str(header.render()) == "Sessions"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_tui.py::test_session_list_header_reflects_archived_mode -v`
Expected: FAIL with `AttributeError: 'SessionList' object has no attribute 'set_mode'`
- [ ] **Step 3: Implement `set_mode`**
In `src/hqt/tui/widgets/session_list.py`, add this method to the `SessionList` class (after `compose`, before `refresh_sessions`):
```python
def set_mode(self, archived: bool) -> None:
"""Swap the column header to reflect whether archived sessions are shown."""
header = self.query_one("#session-header", Label)
header.update("Sessions (archived)" if archived else "Sessions")
```
(`Label` is already imported at the top of this file.)
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_tui.py::test_session_list_header_reflects_archived_mode -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/widgets/session_list.py tests/test_tui.py
git commit -m "feat: SessionList.set_mode swaps header for archived view"
```
---
## Task 5: TUI — archive action (`x`)
**Files:**
- Modify: `src/hqt/tui/app.py` — add `x` binding (after the new `l` binding) and `action_archive_session` (after `action_stop_session`, around line 429)
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_tui.py`:
```python
@pytest.mark.asyncio
async def test_archive_session_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert bindings["x"].action == "archive_session"
@pytest.mark.asyncio
async def test_archive_session_action_calls_service_and_refreshes():
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
seed_db = app._db_factory()
proj = Project(name="arch-proj", path="/tmp/arch-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-arch-action",
archived=False,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
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
mock_service = MagicMock()
mock_service.archive_session = AsyncMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
await app.run_action("archive_session")
await pilot.pause()
mock_service.archive_session.assert_awaited_once_with(sess_id)
mock_service.list_sessions.assert_awaited()
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tui.py -k "archive_session_binding or archive_session_action" -v`
Expected: FAIL — `KeyError: 'x'` and `ActionError` (no `action_archive_session`)
- [ ] **Step 3: Add binding and action**
In `src/hqt/tui/app.py`, add the `x` binding after the `l` binding from Task 3:
```python
Binding("l", "focus_sessions", "Sessions"),
Binding("x", "archive_session", "Archive"),
```
Then add the action after `action_stop_session` (after line 429):
```python
def action_archive_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if not sid:
return
async def _do() -> None:
await self._sessions().archive_session(sid)
await self._refresh_sessions()
self._run_service_worker(_do())
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tui.py -k "archive_session_binding or archive_session_action" -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "feat: archive session action bound to x"
```
---
## Task 6: TUI — archived view toggle (`A`)
**Files:**
- Modify: `src/hqt/tui/app.py` — add `A` binding; add `self._show_archived` in `__init__` (after line 90); thread the flag through `_refresh_sessions` (line 190); guard `_poll_sessions` (line 181); add `action_toggle_archived`
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_tui.py`:
```python
@pytest.mark.asyncio
async def test_toggle_archived_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert bindings["A"].action == "toggle_archived"
@pytest.mark.asyncio
async def test_toggle_archived_switches_list_and_header():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.session_list import SessionList
from textual.widgets import Label
proj_id = 42
app._selected_project_id = proj_id
mock_service = MagicMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
assert app._show_archived is False
await app.run_action("toggle_archived")
await pilot.pause()
assert app._show_archived is True
mock_service.list_sessions.assert_awaited_with(proj_id, archived=True)
header = app.query_one(SessionList).query_one("#session-header", Label)
assert "archived" in str(header.render()).lower()
await app.run_action("toggle_archived")
await pilot.pause()
assert app._show_archived is False
mock_service.list_sessions.assert_awaited_with(proj_id, archived=False)
assert str(header.render()) == "Sessions"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tui.py -k "toggle_archived" -v`
Expected: FAIL — `KeyError: 'A'` and `AttributeError: 'HqtApp' object has no attribute '_show_archived'`
- [ ] **Step 3: Add the flag, binding, action, and thread it through**
In `src/hqt/tui/app.py`:
**(a)** Add the `A` binding after the `x` binding from Task 5:
```python
Binding("x", "archive_session", "Archive"),
Binding("A", "toggle_archived", "Show Archived"),
```
**(b)** Initialize the flag in `__init__`, after line 90 (`self._selected_project_id: int | None = None`):
```python
self._selected_project_id: int | None = None
# When True, the Sessions column shows this project's archived sessions
# instead of its active ones. Persists across project switches.
self._show_archived = False
```
**(c)** Thread the flag into `_refresh_sessions` — change the `list_sessions` call (line 190):
```python
infos = await self._sessions().list_sessions(
self._selected_project_id, archived=self._show_archived
)
```
**(d)** Guard the UI-update block in `_poll_sessions` (lines 181-185) so a poll does not overwrite the archived view with active sessions:
```python
if self._selected_project_id is not None and not self._show_archived:
subset = [
i for i in infos if i.session.project_id == self._selected_project_id
]
await self.query_one(SessionList).refresh_sessions(subset)
```
**(e)** Add the action after `action_archive_session` (from Task 5):
```python
def action_toggle_archived(self) -> None:
self._show_archived = not self._show_archived
self.query_one(SessionList).set_mode(archived=self._show_archived)
self._run_service_worker(self._refresh_sessions(restore_selection=True))
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tui.py -k "toggle_archived" -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "feat: A toggles the sessions column between active and archived"
```
---
## Task 7: TUI — restore action (`u`) and view-aware gating
**Files:**
- Modify: `src/hqt/tui/app.py` — add `u` binding; add `action_unarchive_session`; extend `check_action` (lines 328-337)
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_tui.py`:
```python
@pytest.mark.asyncio
async def test_unarchive_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert bindings["u"].action == "unarchive_session"
@pytest.mark.asyncio
async def test_unarchive_action_calls_service_and_refreshes():
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
seed_db = app._db_factory()
proj = Project(name="restore-proj", path="/tmp/restore-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-restore",
archived=True,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
app._selected_project_id = proj_id
app._show_archived = True
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
mock_service = MagicMock()
mock_service.unarchive_session = AsyncMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
await app.run_action("unarchive_session")
await pilot.pause()
mock_service.unarchive_session.assert_awaited_once_with(sess_id)
mock_service.list_sessions.assert_awaited()
@pytest.mark.asyncio
async def test_archive_restore_actions_gated_by_view():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
# Active view (default): archive offered, restore hidden.
assert app.check_action("archive_session", ()) is True
assert app.check_action("unarchive_session", ()) is None
# Archived view: restore offered, archive hidden.
app._show_archived = True
assert app.check_action("archive_session", ()) is None
assert app.check_action("unarchive_session", ()) is True
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tui.py -k "unarchive or archive_restore_actions_gated" -v`
Expected: FAIL — `KeyError: 'u'`, `ActionError` (no `action_unarchive_session`), and the gating assertions (`check_action` returns `True` for both)
- [ ] **Step 3: Add binding, action, and gating**
In `src/hqt/tui/app.py`:
**(a)** Add the `u` binding after the `A` binding from Task 6:
```python
Binding("A", "toggle_archived", "Show Archived"),
Binding("u", "unarchive_session", "Restore"),
```
**(b)** Extend `check_action` (currently lines 328-337) so `x` is hidden in the archived view and `u` is hidden in the active view. Returning `None` hides the binding from the footer entirely:
```python
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
# Archive applies only to active sessions; restore only to archived ones.
# Hide the inapplicable one so the footer never advertises it.
if action == "archive_session" and self._show_archived:
return None
if action == "unarchive_session" and not self._show_archived:
return None
return True
```
**(c)** Add the action after `action_toggle_archived` (from Task 6):
```python
def action_unarchive_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if not sid:
return
async def _do() -> None:
await self._sessions().unarchive_session(sid)
await self._refresh_sessions()
self._run_service_worker(_do())
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tui.py -k "unarchive or archive_restore_actions_gated" -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "feat: u restores archived sessions; x/u gated by current view"
```
---
## Task 8: Full-suite verification
**Files:** none (verification only)
- [ ] **Step 1: Run the whole test suite**
Run: `uv run pytest -q`
Expected: PASS (all existing tests plus the new navigation/archiving tests)
- [ ] **Step 2: Run the quality gates**
Run: `uv run ruff check .`
Expected: `All checks passed!`
Run: `uv run ty check`
Expected: `All checks passed!`
- [ ] **Step 3: Manual smoke test (optional, requires a terminal)**
Run: `uv run hqt` (or the project's TUI entrypoint), then:
- Press `l`/`h` to move focus between the Sessions and Projects columns.
- Select a session and press `x` — it disappears from the list (window closes).
- Press `A` — the header reads "Sessions (archived)" and the archived session appears.
- Select it and press `u` — it returns to the active list when you press `A` again.
- [ ] **Step 4: Commit any fixes**
If steps 1-2 surfaced issues, fix them and commit:
```bash
git add -A
git commit -m "fix: address verification findings for nav/archiving"
```
```
@@ -1,158 +0,0 @@
# 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.
@@ -1,259 +0,0 @@
# P2 Fixes Design
**Date:** 2026-06-10
**Goal:** Resolve the three P2 findings in `TODO.md` — the Codex session-id
capture race, the absence of real-tmux smoke tests, and unvalidated project
paths — without touching unrelated behavior.
**Scope:** These three findings only. No other P2 hardening, no refactors beyond
what each fix strictly requires.
---
## Finding 1 — Codex session-id capture race
### Problem
`SessionService` captures a Codex rollout id after spawning by scanning
`~/.codex/sessions/rollout-*.jsonl` for the earliest rollout whose `cwd` matches
the project path and whose `started_at >= since` (the spawn timestamp). If a
second Codex session starts in the same project during the retry window
(`CAPTURE_MAX_ATTEMPTS=6` × `CAPTURE_RETRY_INTERVAL=0.5s` ≈ 3s), two rollouts
match and the code blindly picks the earliest — which may be the wrong
conversation. A later `codex resume <id>` then restores the wrong session.
### Approach: serialize hqt starts + refuse ambiguous matches
Two complementary changes. The lock removes the race between hqt-launched
sessions; the ambiguity guard makes a wrong capture impossible even when the
lock cannot help (a Codex started manually by the user in the same directory).
**1. Serialize the spawn→capture critical section.**
Add an `asyncio.Lock` to `SessionService`:
```python
def __init__(self, factory, tmux, harnesses):
self.factory = factory
self.tmux = tmux
self.harnesses = harnesses
# Serializes the spawn->capture window for harnesses that capture a
# session id (codex), so two concurrent hqt starts in the same project
# never overlap and confuse capture_session_id.
self._capture_lock = asyncio.Lock()
```
Hold the lock around the `since = time.time()``tmux.spawn`/`respawn`
`_capture_session_id_with_retry` region, but **only when**
`configurator.captures_session_id` is true (non-capturing harnesses need no
serialization). This applies in two places:
- `create_session` — the initial spawn + capture.
- `_respawn_with_fallback` rung 2 — the fresh-spawn + capture.
Holding the lock across the retry loop means it can be held for up to ~3s, so
rapid session creation against capturing harnesses queues. Session creation is
user-initiated and infrequent, so this is acceptable.
**2. Refuse ambiguous captures in `CodexConfigurator.capture_session_id`.**
Today the method sorts candidates and returns the earliest. Change it so that:
- **0 candidates** → return `None` (unchanged; retry loop tries again).
- **exactly 1 candidate** → return its id (the lock-protected normal case).
- **more than 1 candidate** → ambiguous; log a warning and return `None`
rather than guess.
```python
candidates.sort(key=lambda t: t[0])
if len(candidates) > 1:
log.warning(
"capture_session_id: %d rollouts match cwd=%s since=%s; "
"refusing to guess",
len(candidates), project_path, since,
)
return None
return candidates[0][1] if candidates else None
```
`codex.py` gains a module-level `log = logging.getLogger(__name__)`.
This guard also hardens the poller path
(`SessionService._maybe_capture_missing_session_id`), which calls
`capture_session_id` without the lock: an ambiguous match there now keeps the
placeholder id instead of risking a wrong one.
### Outcome
The worst case becomes "keep the placeholder id" (a missing-but-safe capture),
never "store the wrong id." The placeholder path already exists and is logged.
### Tests (`tests/test_harnesses.py`, `tests/test_sessions.py`)
- `capture_session_id` with two matching rollouts in the same cwd/time window
returns `None` and logs a warning (use `tmp_path` as a fake `~/.codex`,
monkeypatching `Path.home`).
- `capture_session_id` with exactly one matching rollout returns its id
(regression guard for the normal case).
- `SessionService` exposes `_capture_lock` as an `asyncio.Lock`; a unit test
asserts `create_session` acquires it for a capturing harness. (Drive via the
existing fake/mocked tmux + a stub configurator with
`captures_session_id=True`; assert the lock is held during the spawn call,
e.g. by checking `service._capture_lock.locked()` from inside the stub's
`capture_session_id`.)
---
## Finding 2 — Real-tmux smoke tests
### Problem
tmux behavior is exercised almost entirely through mocked `_exec`. Theming, new
windows, respawn, and labels are never validated against a real tmux binary, so
a wrong flag or format string passes the suite.
### Approach: isolated real-tmux server via `TMUX_TMPDIR`, auto-skip
Add `tests/test_tmux_smoke.py`. No source change to `runner.py` is required:
tmux places its server socket in `$TMUX_TMPDIR`, so pointing that at a fresh
temp dir gives a throwaway server fully isolated from the user's real sessions.
**Gating.** Module-level skip when tmux is unavailable:
```python
import shutil
import pytest
pytestmark = [
pytest.mark.tmux,
pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"),
]
```
**Isolation fixture.**
```python
@pytest.fixture
def tmux_runner(tmp_path, monkeypatch):
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
session = "hqt-test"
subprocess.run(
["tmux", "new-session", "-d", "-s", session, "-x", "200", "-y", "50"],
check=True,
env={**os.environ, "TMUX_TMPDIR": str(tmp_path)},
)
runner = TmuxRunner(tmux_path="tmux", session_name=session)
try:
yield runner
finally:
subprocess.run(
["tmux", "kill-server"],
env={**os.environ, "TMUX_TMPDIR": str(tmp_path)},
check=False,
)
```
`TmuxRunner._exec` uses `asyncio.create_subprocess_exec` without an explicit
`env`, so it inherits the monkeypatched `TMUX_TMPDIR` and targets the isolated
server automatically.
**Coverage (one test each, all `async`/`pytest.mark.asyncio`):**
- **new window:** `await runner.new_window("hqt-1", str(tmp_path), "sh")` returns
a pane/window id; the window appears in `await runner.list_windows()`.
- **label round-trip:** `await runner.set_window_label("hqt-1", "•hqt-1")`, then
read the `@hqt_label` user option back via a raw
`tmux show-options -w -t hqt-test:=hqt-1` (or `runner` accessor if present)
and assert it equals what was set.
- **respawn after death:** create a window running a command that exits, confirm
the pane is dead (`await runner.is_pane_dead(...)` is true), then
`await runner.respawn_pane(...)` with a long-lived command and assert the pane
is alive again via `await runner.verify_window_alive(...)`. (Test the runner
directly; `TmuxManager.respawn_verified` is out of scope here.)
- **theme:** `await runner.apply_theme()`, then assert a representative session
option (e.g. `status-justify left` or `status` on) is set via raw
`tmux show-options -t hqt-test`.
**Marker registration (`pyproject.toml`).** Register the `tmux` marker so pytest
emits no unknown-marker warning:
```toml
[tool.pytest.ini_options]
markers = [
"tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server",
]
```
(If a `[tool.pytest.ini_options]` block does not yet exist, add it; otherwise
extend it.)
### Outcome
Real-tmux tests run by default wherever tmux exists (developer machines, CI with
tmux installed) and skip cleanly elsewhere. They never touch the user's live
tmux server because every invocation is scoped to the temp `TMUX_TMPDIR`.
---
## Finding 3 — Project path validation
### Problem
`ProjectService.create`/`update` store whatever path string they are given. A
nonexistent or non-directory path is accepted and only fails later when a
session spawned there dies, surfacing as a confusing dead session rather than an
early, clear rejection.
### Approach: validate and normalize at create/update
Add a private helper to `ProjectService`:
```python
from pathlib import Path
def _normalize_path(self, raw: str) -> str:
"""Expand ~, require an existing directory, return the resolved absolute path.
Raises ServiceError if the path does not exist or is not a directory, so the
TUI surfaces it as a notification instead of letting the bad path become a
dead session later.
"""
path = Path(raw).expanduser()
if not path.is_dir():
raise ServiceError(f"Path does not exist or is not a directory: {raw}")
return str(path.resolve())
```
`create` and `update` call `path = self._normalize_path(path)` before
constructing/assigning the `Project`, so the stored path is always an absolute,
existing directory. The existing duplicate-path `IntegrityError` handling is
unchanged and runs after normalization (so duplicate detection compares resolved
paths consistently).
**No TUI change.** `action_add_project` (`app.py:201-205`) and
`action_edit_project` (`app.py:223-227`) already catch `ServiceError` and call
`self.notify(str(err), severity="error")`, so a rejected path shows as an error
notification with no further wiring.
### Tests (`tests/test_services.py`)
- `create` with a nonexistent path raises `ServiceError`.
- `create` with a path that is a file (not a directory) raises `ServiceError`.
- `create` with a valid directory stores the resolved absolute path
(assert `project.path == str(tmp_path.resolve())`).
- `create` with a `~`-prefixed path expands it (monkeypatch `Path.home` or
`HOME` to `tmp_path` and assert expansion).
- `update` with a nonexistent path raises `ServiceError` and leaves the row
unchanged (re-read after `db.expire_all()`).
---
## Out of scope
- Any change to `src/hqt/tmux/runner.py` (it carries unrelated uncommitted WIP;
the chosen designs deliberately avoid touching it).
- Broader Codex correctness work (e.g. matching on an injected marker, or
isolating `CODEX_HOME` — rejected because a per-session `CODEX_HOME` would also
isolate Codex's `config.toml`/auth and break the harness).
- Any new P3-level hardening or refactors.
@@ -1,204 +0,0 @@
# Sandboxed sessions via bubblewrap — design
**Date:** 2026-06-10
**Status:** Approved (pending implementation plan)
## Problem
Users want to start harness sessions inside a [bubblewrap](https://github.com/containers/bubblewrap)
(`bwrap`) sandbox. Inside the jail the agent can run freely — so the harness is
launched with its "skip permissions" flag (`--dangerously-skip-permissions` for
Claude Code, the equivalent for other harnesses) — while bubblewrap provides the
real safety boundary: controlled filesystem access (read-only or read-write to
the project directory) and optional network access. The configuration must be
quick to set per session.
## Decisions (from brainstorming)
- **Config scope:** per session, chosen in the New Session dialog and stored on
the session row so resume/respawn reuse it.
- **Controls:** a "Sandboxed" toggle plus two sub-toggles — filesystem mode
(read-only / read-write) and network (on / off).
- **Skip-permissions:** automatic. Sandbox on ⇒ the harness's skip-permissions
flag is added. The bwrap container is the safety boundary, so there is no
separate widget for it.
- **Filesystem model:** minimal allowlist. User data (`$HOME` and other
directories) is hidden. System directories are bound read-only so binaries and
libraries work; the project directory and a per-harness set of
credential/config paths are bound explicitly.
- **Defaults when sandbox is enabled:** read-write project dir, network on (the
common "let it work, but contained" case; the harness needs network to reach a
hosted model API).
- **bwrap missing:** hard failure, never a silent downgrade to unsandboxed.
## Key constraint: network is all-or-nothing
bubblewrap toggles networking at the namespace level — it cannot selectively
allow the model API while blocking everything else. Therefore:
- **Network on** = the sandbox shares the host network namespace; the agent (and
its tools: `curl`, `git push`, etc.) can reach anything the host can.
- **Network off** = no connectivity at all. The harness process itself cannot
reach a hosted model API, so this mode is only useful with local/offline
models. The toggle's value is blocking the agent's network for offline or
untrusted-code review.
This constraint is surfaced in the UI defaults (network defaults on).
## Architecture
### 1. Data model
Add one nullable column to `Session`:
```python
sandbox_json: Mapped[str | None] = mapped_column(default=None)
```
`NULL` = unsandboxed. When set it holds:
```json
{ "fs": "rw" | "ro", "net": true }
```
Presence of the value means sandboxed; `fs` and `net` capture the two
sub-toggles. Skip-permissions is *derived* (sandboxed ⇒ on) and is not stored.
A single `user_version`-based migration (see `db/migrations.py`) adds the column.
Because the config lives on the row, `attach_session``_respawn_with_fallback`
and `_get_resume_config` rebuild the same sandboxed command after a stop.
### 2. The configuration ABC — `HarnessConfigurator`
Per-harness knowledge lives here. Two additions to `harnesses/base.py`:
```python
class Bind:
"""A path to expose inside the sandbox."""
src: Path
writable: bool = False
class HarnessConfigurator(ABC):
...
sandbox_skip_permission_flags: list[str] = []
def sandbox_binds(self) -> list[Bind]:
return []
```
Per-harness values:
| Harness | `sandbox_skip_permission_flags` | `sandbox_binds()` |
|-----------|----------------------------------------------|--------------------------|
| `claude` | `["--dangerously-skip-permissions"]` | `~/.claude` (rw) |
| `codex` | `["--dangerously-bypass-approvals-and-sandbox"]` | `~/.codex` (rw) |
| `kiro` | `[]` | `~/.kiro` (rw) |
| `generic` | `[]` | `[]` |
The exact flag spelling and credential paths must be verified against each
installed binary during implementation (e.g. `claude --help`); the values above
are the expected defaults.
`build_spawn_config` / `build_resume_config` gain a `sandboxed: bool = False`
parameter. When true, the configurator appends
`self.sandbox_skip_permission_flags` to the command list. The configurator does
**not** construct the bwrap invocation — it only declares flags and binds.
### 3. Bubblewrap policy module — `hqt/sandbox.py`
A pure, argv-only function:
```python
@dataclass(frozen=True)
class SandboxPolicy:
fs: str # "rw" | "ro"
net: bool
def wrap(
command: list[str],
cwd: Path,
policy: SandboxPolicy,
binds: list[Bind],
) -> list[str]:
"""Return the `bwrap … -- <command>` argv."""
```
Assembled from three layers:
- **Base (always):** `--unshare-all`, `--die-with-parent`; RO binds of system
dirs (`/usr`, `/bin`, `/lib`, `/lib64`, and curated `/etc` essentials such as
`resolv.conf`, `ssl`, `passwd`); `--proc /proc`; `--dev /dev`; `--tmpfs /tmp`;
pass-through of `PATH`, `HOME`, `TERM`, `LANG`. `$HOME` itself is not bound, so
user data is hidden. `~/.gitconfig` bound RO when present.
- **Harness binds:** each `Bind` from the configurator, `--bind` (writable) or
`--ro-bind`, creating parent dirs as needed.
- **cwd:** `--bind cwd cwd` when `fs == "rw"`, else `--ro-bind cwd cwd`.
- **net:** when `policy.net` is true, the network namespace is shared (omit the
net-unshare); otherwise it stays unshared and there is no connectivity.
Being pure and producing only argv (no subprocess), `wrap` is unit-testable
without bwrap installed.
### 4. Spawn / resume integration — `SessionService`
In `create_session`: call `build_spawn_config(..., sandboxed=True)` when the
request is sandboxed, then pass `SpawnConfig.command` through `sandbox.wrap(...)`
(using the configurator's `sandbox_binds()` and the session policy) before
constructing the `SpawnRequest`. `cwd` remains the project path — bwrap binds it.
The same wrapping applies in `_respawn_with_fallback` (both the resume rung and
the fresh-spawn rung) and in `_get_resume_config`, reading the policy back from
`sandbox_json`.
`create_session` gains a `sandbox` parameter (the parsed policy or `None`).
### 5. UI — New Session dialog
`tui/screens/new_session.py` adds:
- A `Switch` labelled "Sandboxed" (default off).
- Revealed when on: a filesystem `Select` ("Read-write" / "Read-only", default
Read-write) and a "Network" `Switch` (default on).
The dialog's result tuple is extended to carry the sandbox config (or `None`),
threaded into `create_session`.
**bwrap availability gates the toggle.** The dialog checks bubblewrap
availability (the same check `doctor` uses — see §6) on mount. When bwrap is
unavailable, the "Sandboxed" switch is disabled (cannot be turned on) and an
inline warning explains why (e.g. "bubblewrap not found — run `hqt doctor`").
This makes the unavailable state visible up front rather than only at spawn.
### 6. Availability check, doctor & failure handling
- A single shared helper (e.g. `sandbox.is_available()`) reports whether `bwrap`
is on `PATH` and the platform is Linux. Both `doctor` and the New Session
dialog use it, so there is one source of truth.
- `hqt doctor` reports bubblewrap availability as an optional capability
(present / missing), alongside the existing checks.
- The dialog uses the helper to disable the Sandboxed toggle (§5), so an
unsandboxable environment is caught before the user picks anything.
- As a backstop (defense in depth), if a sandboxed session somehow reaches spawn
while bwrap is unavailable, spawn fails loudly with a `ServiceError` — never a
silent downgrade to an unsandboxed session.
## Testing
- **`sandbox.wrap` unit tests:** assert the argv for each toggle combination
(rw/ro × net/no-net), that harness binds are spliced in with the right
`--bind`/`--ro-bind`, and that the command appears after `--`.
- **Configurator tests:** skip-permission flags are appended only when
`sandboxed=True`; absent otherwise.
- **Service test:** a sandboxed `create_session` wraps the command in `bwrap`;
a bwrap-missing environment raises `ServiceError`.
- **Availability/UI test:** `sandbox.is_available()` is false when `bwrap` is
absent or the platform is non-Linux, and the dialog disables the Sandboxed
toggle in that case.
- **Migration test:** the new column is added and round-trips a policy.
## Out of scope (YAGNI for v1)
- Per-project or named global sandbox profiles (per-session only for now).
- A freeform "extra bind paths" field in the dialog.
- Selective network filtering (proxy/firewall) — bwrap is namespace-level only.
- Remembering the last-used sandbox config as a default.
@@ -1,87 +0,0 @@
# 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).
@@ -1,269 +0,0 @@
# Tool palette: nvim / lazygit / shell / clone per session
**Date:** 2026-06-10
**Status:** Approved (revised — adds the Alt+p palette and the clone action)
## Goal
From anywhere inside a harness pane, press **Alt+p** to get a small fuzzy
launcher for the current session's project:
- **nvim / lazygit / shell** — open the tool in a **new tmux window appended at
the next free index**, styled exactly like hqt's own windows, in the session's
project directory. The window closes when you quit the tool.
- **clone** — open a **fresh harness session** (a real `hqt-<id>` window) with the
**same project, harness, and model** as the current session, but a brand-new
conversation.
One key (Alt+p), one fuzzy list, so there are no per-tool shortcuts to memorize.
The palette is a tmux binding, so it works over the TUI and inside any harness.
## Background
A session is a tmux window named `tmux_session_name` (`hqt-{id}`), created in its
project's directory by `TmuxRunner.new_window()`, which appends at
`_next_window_index()` (= `max(window indices) + 1`) and applies the Frappé
per-window theme. A session row stores `project_id`, `harness_id`, and `model`
(`db/models.py`), so cloning is just `create_session` with those three values.
Three facts make this design cheap and safe:
- hqt tracks windows **by name, keyed to DB sessions**, and **never prunes
unknown windows**. `sync_window_labels()` (the 3s poll) only labels rows it
knows. A window that is not a DB session is never killed and never relabeled.
- The status-bar cell renders `#I:#{?@hqt_label,#{@hqt_label},#W}#F`. A window
with no `@hqt_label` falls back to its raw name; setting `@hqt_label` gives a
tool window a clean label and makes it show nicely in the `Alt+o` switcher too.
- `new_window()` hardcodes `remain-on-exit on` (so a harness that dies instantly
leaves a visible pane). Tool windows want the **opposite** — close when you
quit — which is tmux's default. Hence a separate spawn path
(`new_aux_window`) rather than a flag on `new_window()`.
So a **tool window** is purely a tmux window: hqt spawns and styles it, then
forgets it. A **clone** is the opposite — a fully tracked session created through
the existing `create_session` path, so it appears in the session list on the next
poll with no special handling.
## Architecture: how Alt+p reaches hqt with the right window
Two tmux facts (verified empirically on tmux 3.6b) shape the bridge:
- **`run-shell` format-expands its command.** `run-shell "echo #{window_name}"`
runs `echo hqt-5`.
- **`display-popup` does NOT expand its command (or its `-e` value).** The fzf
popup needs `display-popup` for an interactive terminal, but it receives the
literal string `#{window_name}` — useless. And `display-message -p
'#{window_name}'` *inside* the popup resolves against the "current" client,
which is ambiguous when more than one client is attached (it returned the wrong
window in testing).
So the binding bridges through `run-shell` (which expands the name) into a small
CLI subcommand that bakes the resolved name into the popup as a literal:
```tmux
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
```
`run-shell` expands `#{window_name}``hqt palette 'hqt-5'`. `hqt palette` then
builds and runs `display-popup -E "<fzf> | xargs -I{} hqt tool {} hqt-5"`, where
`hqt-5` is a concrete literal — no further tmux expansion required, and no
multi-client ambiguity. The selected entry runs `hqt tool <choice> hqt-5`, which
funnels into the same service code the rest of hqt uses. No IPC, no daemon.
`-b` keeps the tmux server responsive during hqt's ~0.30.6s startup.
**TUI note.** From the TUI home window, `#{window_name}` is the TUI window, not a
session — the palette can't know which session is *highlighted* there. So the
palette is, by design, for harness/session windows; from the TUI you attach
(Enter) first, then Alt+p. From a non-session window the palette shows a brief
hint instead of an unusable menu (see `hqt palette` below). This is the accepted
trade-off of a single tmux-level key over a TUI-specific palette.
## Components
### 1. Tool registry — `src/hqt/tools.py` (new)
Maps a tool name to its spawn spec:
```python
@dataclass(frozen=True)
class Tool:
label: str # base label for @hqt_label, e.g. "nvim"
command: list[str] # [] => tmux default shell (the plain shell)
TOOLS: dict[str, Tool] = {
"nvim": Tool(label="nvim", command=["nvim"]),
"lazygit": Tool(label="lazygit", command=["lazygit"]),
"shell": Tool(label="shell", command=[]),
}
```
`clone` is **not** in this registry — it creates a session, not an aux window —
and is handled as a distinct path. The per-spawn status label is
`f"{tool.label} · {project_name}"` so the always-fresh windows stay
distinguishable.
### 2. `TmuxRunner.new_aux_window(name, cwd, command, label) -> str | None` (new)
The aux-window workhorse. Everything after creation is targeted by **`window_id`**
(e.g. `@7`), not name — so duplicate tool-window names are never ambiguous.
1. `idx = await self._next_window_index()` (appends at the right).
2. `new-window -t <session>:<idx> -n <name> -c <cwd> -P -F '#{window_id}'`,
appending the joined `command` when non-empty (empty → default shell). Capture
the returned `window_id`. **No `remain-on-exit`.**
3. One atomic `set-option` call (`;`-separated argv) on the `window_id`:
`automatic-rename off`, `@hqt_label <label>`, then `*_window_theme_args(wid)`.
4. `select-window -t <window_id>`.
5. Return the `window_id`; on `new-window` failure return `None` (logged); on
`set-option` failure kill the half-built window and return `None`.
### 3. `TmuxManager.open_aux_window(name, cwd, command, label) -> str | None` (new)
Thin delegate to `runner.new_aux_window`, matching the manager/runner layering.
### 4. `SessionService` methods (new)
```python
def session_id_for_window(self, window_name: str) -> int | None:
"""Resolve a tmux window name to its active hqt session id, or None."""
with self.factory() as db:
sess = (
db.query(Session)
.filter_by(tmux_session_name=window_name, archived=False)
.first()
)
return sess.id if sess else None
```
```python
async def open_tool_window(self, session_id: int, tool: str) -> str | None:
spec = TOOLS.get(tool)
if spec is None:
raise ServiceError(f"Unknown tool '{tool}'")
if spec.command and shutil.which(spec.command[0]) is None:
raise ServiceError(f"{spec.command[0]} not found on PATH")
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
raise ServiceError("Session not found")
project = db.get(Project, sess.project_id)
if project is None:
raise ServiceError("Project no longer exists")
cwd, label = project.path, f"{spec.label} · {project.name}"
return await self.tmux.open_aux_window(spec.label, cwd, spec.command, label)
```
`open_tool_window_for_window(window_name, tool)` resolves via
`session_id_for_window` and raises `ServiceError` when the name is not a session.
The `shutil.which` pre-check turns a missing `lazygit` into a clean message rather
than a window that flashes and vanishes.
```python
async def clone_session_for_window(self, window_name: str) -> CreateSessionResult:
with self.factory() as db:
sess = (
db.query(Session)
.options(selectinload(Session.harness))
.filter_by(tmux_session_name=window_name, archived=False)
.first()
)
if sess is None:
raise ServiceError(f"{window_name!r} is not an hqt session window")
project_id, harness_name, model = sess.project_id, sess.harness.name, sess.model
return await self.create_session(project_id, harness_name, nickname=None, model=model)
```
clone reuses `create_session` wholesale (spawn + capture-retry + DB row). Because
a tool window or the TUI home window does not resolve to a session, invoking clone
from there raises `ServiceError` and nothing happens — exactly the "do nothing
from a vim/shell window" requirement, for free.
### 5. CLI — `src/hqt/cli.py`
Two subcommands, sharing one `_build_session_service()` helper that mirrors
`HqtApp.on_mount`'s wiring (`TmuxRunner``TmuxManager` → `SessionService(factory,
tmux, discover_harnesses())`).
- **`hqt tool <name> <window>`** — dispatch: `name == "clone"`
`clone_session_for_window(window)`; otherwise →
`open_tool_window_for_window(window, name)`. On `ServiceError`, print to stderr
and exit 1 (the popup surfaces it briefly).
- **`hqt palette <window>`** — if `session_id_for_window(window)` is `None`, run
`tmux display-message "hqt: open the tool palette from a harness window"` and
return (graceful no-op from a tool/TUI window). Otherwise run
`tmux display-popup -E ...` whose command is:
```
printf 'nvim\nlazygit\nshell\nclone\n' \
| fzf --reverse --no-info --prompt='tool ' --pointer='▌' --color='<frappé>' \
| xargs -r -I{} hqt tool {} '<window>'
```
The window name is baked in as a `shlex.quote`d literal. fzf colors match the
`Alt+o` switcher (Catppuccin Frappé). Cancelling fzf (`xargs -r`) runs nothing.
### 6. `~/.tmux.conf` (user-owned keybindings file)
One global binding, in the existing comment style:
```tmux
# Tool palette: Alt+p pops an fzf launcher for the CURRENT hqt session's project
# (works inside a harness). Pick nvim / lazygit / shell to open a styled window at
# the next index, or "clone" for a fresh harness with the same project+model.
# run-shell expands #{window_name} (the hqt-<id> key) and hands it to `hqt palette`,
# which builds the popup — display-popup does NOT expand formats, so the name is
# resolved here. From a non-session window it shows a brief hint.
bind -n M-p run-shell -b "hqt palette '#{window_name}'"
```
## Data flow
`Alt+p``run-shell` expands `#{window_name}``hqt palette hqt-5`
[not a session? → tmux message, done] → `display-popup` fzf → selection →
`hqt tool <choice> hqt-5`
- tool → resolve project → `new_aux_window(cwd=project, command=tool, label)`
styled window appended at the next index → `select-window`. Never written to the
DB, so the 3s poll ignores it and hqt never prunes it.
- clone → resolve project/harness/model → `create_session(...)` → fresh `hqt-<id>`
window; the running TUI's next poll lists it.
## Error handling
- Missing binary → `ServiceError` → CLI stderr + exit 1 (popup shows it briefly).
- Not a session window (tool window, TUI home, unrelated window) → `hqt palette`
shows a one-line tmux hint and never opens the menu; a direct `hqt tool` raises
`ServiceError`.
- `new-window` / `set-option` failure → logged; aux path returns `None` and cleans
up a half-built window.
## Testing
Mirror existing patterns:
- **Service** (`MagicMock(spec=TmuxManager)`): `open_tool_window` raises on
unknown tool / missing binary (monkeypatched `shutil.which`) / missing session;
passes the right `cwd`/command/label on success. `session_id_for_window`
resolves a known name and returns `None` for an unknown one.
`clone_session_for_window` calls `create_session` with the source session's
project/harness/model and raises for an unknown window.
- **Runner** (`AsyncMock` `_exec` side-effect queue): `new_aux_window` issues
next-index → `new-window -P -F``set-option`(by `window_id`) → `select-window`
in order, **never** sets `remain-on-exit`; covers a command tool and the empty
shell.
- **CLI** (`CliRunner`, monkeypatched service + `subprocess.run`): `hqt tool`
dispatches clone vs. tool and maps `ServiceError` → exit 1; `hqt palette` runs
`display-message` for a non-session window and `display-popup` (command
containing the entries and the quoted window) for a session window.
## Out of scope
- No TUI-side palette or per-tool TUI bindings — Alt+p (tmux) is the only trigger
(decision: one consistent key, no Ctrl+P clobbering nvim/shell). The cost is
that launching from the TUI home window is a no-op hint; attach first.
- No reuse/dedupe of tool windows (always spawn new).
- No DB rows or status-glyph logic for tool windows.
- No configurability beyond the `TOOLS` registry and the palette entry list.
- clone copies project/harness/model only — not nickname, MCP, or skill overrides
(a fresh sibling, not a deep copy).
@@ -1,126 +0,0 @@
# Worktree-Isolated Sessions — Design
Date: 2026-06-10
Status: Approved
## Goal
Allow a new session to run in a git worktree of its project so the harness
operates on an isolated checkout, on its own branch, without trampling the
main checkout or other sessions.
## Decisions (from brainstorming)
- **Lifecycle:** Feature-branch work. hqt creates worktrees but never
auto-deletes work. Cleanup is explicit and guarded.
- **Branching:** The user names the branch in the new-session dialog; hqt
creates it with `git worktree add -b <branch>` based off the project's
current HEAD. No existing-branch checkout, no auto-generated names.
- **Location:** `<project>/.worktrees/<branch>`. hqt ensures `.worktrees/` is
listed in the repo's `.git/info/exclude` (idempotent append; resolved via
`git rev-parse --git-common-dir` so it works when the project itself is a
worktree).
- **Deletion:** Deleting a worktree session prompts with an "Also remove
worktree" option and a safety check. Dirty trees or branches with commits
unreachable from other refs require explicit opt-in (maps to
`git worktree remove --force`). Branches are only deleted with safe
`git branch -d`, so unmerged branches always survive.
- **Dialog UX:** Checkbox "Isolate in worktree" + branch input (prefilled from
the slugified nickname). Disabled with a hint when the project is not a git
repo.
- **Setup:** None. Fresh worktrees have no deps/env files; the agent in the
session handles its own setup.
- **Base ref:** Current HEAD of the main checkout.
## Architecture (Approach A)
### Data model
`Session` gains two nullable columns; both NULL means a normal session (no
boolean flag):
- `worktree_path: str | None` — absolute path of the worktree
- `worktree_branch: str | None` — branch name
Migration version 2 in `hqt/db/migrations.py` `MIGRATIONS` with two
`ALTER TABLE sessions ADD COLUMN` statements.
### Git layer: `src/hqt/git/worktree.py`
Async subprocess wrappers around git, mirroring `tmux/runner.py`. Failures
raise `ServiceError` carrying git's stderr.
- `slugify(text) -> str` — lowercase, keep alphanumerics, collapse runs of
other chars to `-`, strip leading/trailing `-`.
- `is_git_repo(path) -> bool``git rev-parse --is-inside-work-tree`.
- `create_worktree(repo, branch) -> Path` — validate branch name
(`git check-ref-format --branch`), ensure `.worktrees/` in
`.git/info/exclude`, run `git worktree add -b <branch> .worktrees/<branch>`.
Returns absolute worktree path.
- `worktree_state(repo, path, branch) -> WorktreeState` — dataclass:
`exists: bool`, `dirty: bool` (`git status --porcelain` in the worktree),
`unique_commits: int` (commits on `branch` not reachable from any other
ref).
- `remove_worktree(repo, path, branch, force) -> None`
`git worktree remove` (`--force` only when the user confirmed past the
warning), then best-effort `git branch -d` (keep branch if unmerged), then
`git worktree prune`.
- `add_worktree_for_branch(repo, branch) -> Path` — `git worktree add <path>
<branch>` (no `-b`); used to recreate a vanished worktree on attach.
### SessionService changes
- `create_session(..., worktree_branch: str | None = None)`. When set:
resolve project path → `create_worktree` (failure raises ServiceError
before any row exists) → create Session row with `worktree_path` /
`worktree_branch` → spawn with `cwd=worktree_path`.
- `_session_path(db, sess) -> Path` returns
`Path(sess.worktree_path) or project.path` and replaces `_project_path` at
every harness-facing call site: `build_spawn_config`,
`build_resume_config`, and `capture_session_id` (Claude keys session files
by cwd, which is now the worktree).
- Attach rung 0: if `worktree_path` is set but missing on disk and the
branch still exists, recreate via `add_worktree_for_branch` and continue
the normal resume ladder. If the branch is gone too, raise
`ServiceError("Worktree and branch are gone; delete the session")`.
- `delete_session(session_id, remove_worktree: bool = False, force: bool =
False)`. When removal is requested and fails, neither the worktree nor the
DB row is deleted (no half-deleted state); the error surfaces as a
notification.
### TUI changes
- `NewSessionScreen`: constructor gains `is_git_repo: bool`. Adds a
"Isolate in worktree" checkbox (disabled + "(not a git repo)" hint when
false) and a branch `Input`, hidden until checked, prefilled with the
slugified nickname (editable). Result tuple extends with
`worktree_branch: str | None`.
- New `ConfirmDeleteScreen` modal, used only for worktree sessions
(non-worktree `d` keeps today's instant behavior): shows session name,
"Also remove worktree" checkbox. The app fetches `worktree_state` before
pushing it; if dirty or `unique_commits > 0`, show a warning line ("N
uncommitted files, M unmerged commits — work will be lost") and start the
checkbox unchecked; checked-through-warning maps to `force=True`.
- Session list rows and tmux window labels mark worktree sessions with
`⎇ <branch>`; nickname defaults to the branch name when left empty.
## Corner cases
- Branch exists / invalid name / leftover `.worktrees/<branch>` dir → git
fails, ServiceError notification, no session row.
- Non-git project → checkbox disabled; service raises if called anyway.
- Project path deleted with live worktree sessions → same as today (capture
skipped, attach errors).
- Submodules are not initialized in fresh worktrees — documented limitation.
- `git worktree remove` fails (e.g. process cwd inside) → notification,
session row kept.
## Testing
- `tests/test_worktree.py`: git module against real temp git repos (init,
commit; create/remove/state; exclude idempotence; invalid names).
- Service tests: monkeypatch worktree functions; verify ordering (worktree
before row, no row on failure), spawn/resume/capture cwd, attach rung 0,
guarded delete.
- TUI tests: dialog return values, checkbox-disabled path, confirm modal
wiring.
@@ -0,0 +1,85 @@
# TUI Column Navigation & Session Archiving — Design
**Date:** 2026-06-11
**Status:** Approved (ready for implementation plan)
## Goal
Two TUI-UX improvements to hqt's main view:
1. **h/l column navigation** — vim-style horizontal movement between the Projects and Sessions columns, complementing the existing `j/k` within-list movement.
2. **Session archiving** — a recoverable "remove from view" that tears down a finished session's tmux window but keeps its DB record, with a per-project toggle to view and restore archived sessions.
## Background / Current State
- Main view (`src/hqt/tui/app.py`) composes `ProjectList` (docked left) + `SessionList` (fills right) + `Footer`.
- Within-list movement uses `VimListView` (`j`/`k` plus arrows). Cross-column movement today is `Tab``screen.focus_next()` (cycles ProjectList → SessionList → Footer).
- `Session` and `Project` models already carry an `archived: bool` column (`src/hqt/db/models.py`). Every session read path already filters `archived=False` (`list_sessions`, `sync_window_labels`, `session_id_for_window`). **No DB migration is required** — the column exists at baseline schema v1.
- `delete_session` (`src/hqt/sessions/service.py`) kills the tmux window (if alive), removes any worktree (optional), deletes the scheduled prompt, and deletes the DB row. Archiving will reuse the window-teardown portion of this path.
## Feature A — h/l Column Navigation
Add two app-level bindings in `app.py`:
- `h``action_focus_projects`: focus the `#project-list` `VimListView`.
- `l``action_focus_sessions`: focus the `#session-list` `VimListView`.
These focus a specific widget by id (not `focus_next()`), so they are directional regardless of current focus. The existing `Tab``action_toggle_focus` binding **stays** as a cycle alias. No CSS/layout changes.
**Behavior details:**
- Selecting a project continues to drive which sessions are shown (unchanged).
- Pressing `l` when the selected project has zero sessions still moves focus to the (empty) Sessions list — consistent with how `Tab` behaves today.
## Feature B — Session Archiving
### Semantics
Archiving is a **recoverable delete**: it kills the session's tmux window/harness process (freeing resources) but **keeps the DB row** including `harness_session_id`, so the record can be referenced or restored later. It is the everyday "I'm done with this" action; hard `delete` remains for permanent removal.
### Service layer (`src/hqt/sessions/service.py`)
- Parameterize the read path: `list_sessions(project_id, archived=False, now=None)` — same query, with the archived flag configurable.
- `archive_session(session_id)`: kill the tmux window if it exists (reuse the existing teardown used by `delete_session`), then set `archived=True` and commit. Does **not** remove the worktree or DB row.
- `unarchive_session(session_id)`: set `archived=False` and commit. The window stays gone — the restored session is a dead, recoverable record.
`sync_window_labels` and other live-session machinery continue to filter `archived=False`, so archived rows are never polled or labeled.
### TUI actions & keybindings (`src/hqt/tui/app.py`)
| Key | View | Action |
|-----|-----------|----------------------------------------------------------------|
| `x` | active | Archive selected session — **instant, no confirmation** (non-destructive: row + worktree kept, harness resumable) |
| `A` | both | Toggle the Sessions column between **active** and **archived** |
| `u` | archived | Restore (unarchive) the selected session |
| `d` | both | Hard delete (unchanged; in archived view the window is already gone) |
Archive is instant because it loses nothing: it kills the live tmux window (a resumable process) but keeps the DB row, `harness_session_id`, and any worktree on disk. Restore is equally instant. Hard `delete` retains its existing confirmation flow (worktree sessions still prompt via `ConfirmDeleteScreen`).
### View toggle
- A `_show_archived: bool` flag on the app. `action_toggle_archived` flips it and refreshes the Sessions list for the currently-selected project.
- When archived, the Sessions header reads **"Sessions (archived)"**; when active it reads **"Sessions"**.
- The flag **persists across project switches** (predictable, single global mode). `_refresh_sessions` reads `archived=self._show_archived` when calling `list_sessions`.
- Footer adapts via Textual's `check_action`: `x` is offered only in active view, `u` only in archived view. The footer never advertises an inapplicable action.
### Status of archived sessions
Archived sessions always render as `dead` (their window was killed). No pane capture or status parsing is needed for them; `_poll_sessions` continues to operate on the active list only.
## Out of Scope
- Archiving **projects** (the `Project.archived` column exists but no project-archiving UI is part of this work).
- Worktree removal on archive (archive keeps the row and any worktree; hard delete still handles worktree teardown).
- Bulk archive/restore.
## Testing
**Service tests (`tests/test_sessions.py`):**
- `archive_session` kills the window, keeps the row, and the row is excluded from `list_sessions(archived=False)`.
- `unarchive_session` makes the row reappear in the active list.
- `list_sessions(project_id, archived=True)` returns only archived rows.
**TUI tests (`tests/test_tui.py`, following existing patterns):**
- `x` triggers archive (instant, no dialog), `A` toggles the view and header text, `u` restores.
- `h`/`l` move focus to the projects/sessions lists respectively.
- Footer/`check_action` adaptation: `x` hidden in archived view, `u` hidden in active view.
+72 -4
View File
@@ -1,6 +1,7 @@
import logging import logging
import os import os
import shlex import shlex
import shutil
import subprocess import subprocess
import sys import sys
import traceback import traceback
@@ -106,7 +107,7 @@ def launch(inside_tmux: bool = False):
@main.command() @main.command()
def doctor(): def doctor():
"""Check system requirements.""" """Check system requirements."""
import shutil from hqt import sandbox
from hqt.harnesses.registry import discover_harnesses from hqt.harnesses.registry import discover_harnesses
tmux = shutil.which("tmux") tmux = shutil.which("tmux")
@@ -120,6 +121,12 @@ def doctor():
click.echo(f"{name}: ✓") click.echo(f"{name}: ✓")
if not harnesses: if not harnesses:
click.echo("No harnesses found on PATH") click.echo("No harnesses found on PATH")
# Use the shared helper so doctor agrees with the New Session dialog and the
# spawn backstop (single source of truth: Linux + bwrap on PATH).
if sandbox.is_available():
click.echo(f"bubblewrap: ✓ {shutil.which('bwrap')}")
else:
click.echo("bubblewrap: ✗ not found — sandboxed sessions unavailable")
def _build_session_service(): def _build_session_service():
@@ -151,8 +158,11 @@ def _build_session_service():
def tool_cmd(tool, window): def tool_cmd(tool, window):
"""Run TOOL for the session in tmux WINDOW. """Run TOOL for the session in tmux WINDOW.
TOOL is nvim/lazygit/shell (opens a styled tool window) or "clone" (a fresh TOOL is nvim/lazygit/shell (opens a styled tool window), "clone" (a fresh
harness with the same project + model). WINDOW is the tmux window name (the harness with the same project + model), "stop" (kill the harness and close
its window, like the TUI's `s`), "rename" (prompt for a new nickname, like
the TUI's `r`), or "reset-windows" (compact the hqt session's tmux window
indexes, keeping the home window at 0). WINDOW is the tmux window name (the
hqt-<id> key, e.g. from #{window_name}). hqt-<id> key, e.g. from #{window_name}).
""" """
import asyncio import asyncio
@@ -163,6 +173,22 @@ def tool_cmd(tool, window):
try: try:
if tool == "clone": if tool == "clone":
asyncio.run(svc.clone_session_for_window(window)) asyncio.run(svc.clone_session_for_window(window))
elif tool == "stop":
asyncio.run(svc.stop_session_for_window(window))
elif tool == "rename":
session_id = svc.require_session_id_for_window(window)
sess = svc.get_session(session_id)
current = (sess.nickname or "") if sess else ""
new = _read_line_from_tty("rename (blank clears): ", current)
if new is not None: # None = no tty / aborted; leave the name as-is
svc.rename_session(session_id, new)
elif tool == "reset-windows":
from hqt.config import get_settings
home_window = get_settings().tui_window_name
if not asyncio.run(svc.tmux.reset_window_indexes(home_window)):
click.echo("hqt: failed to reset window indexes", err=True)
raise SystemExit(1)
else: else:
asyncio.run(svc.open_tool_window_for_window(window, tool)) asyncio.run(svc.open_tool_window_for_window(window, tool))
except ServiceError as err: except ServiceError as err:
@@ -170,7 +196,49 @@ def tool_cmd(tool, window):
raise SystemExit(1) raise SystemExit(1)
PALETTE_ENTRIES = ["nvim", "lazygit", "shell", "clone"] PALETTE_ENTRIES = [
"nvim",
"lazygit",
"shell",
"clone",
"stop",
"rename",
"reset-windows",
]
def _read_line_from_tty(prompt: str, prefill: str = "") -> str | None:
"""Read one edited line from the controlling tty, pre-filled with `prefill`.
The Alt+p palette runs us downstream of its `fzf | xargs` pipe, so our stdin
is the spent pipe not a terminal. We talk to /dev/tty directly (the popup's
terminal, exactly as fzf does) by dup'ing it onto fds 0/1: GNU readline (and
thus the editable prefill) only engages when input() reads real fd 0. The
name never round-trips through tmux's command parser, so there's nothing to
quote-escape. Returns None when there's no tty or the user aborts (EOF).
"""
import readline
try:
tty_fd = os.open("/dev/tty", os.O_RDWR)
except OSError:
return None
saved_in, saved_out = os.dup(0), os.dup(1)
try:
os.dup2(tty_fd, 0)
os.dup2(tty_fd, 1)
readline.set_startup_hook(lambda: readline.insert_text(prefill))
try:
return input(prompt)
except EOFError:
return None
finally:
readline.set_startup_hook()
os.dup2(saved_in, 0)
os.dup2(saved_out, 1)
for fd in (saved_in, saved_out, tty_fd):
os.close(fd)
# fzf colors matching the Catppuccin Frappé status bar / the Alt+o switcher. # fzf colors matching the Catppuccin Frappé status bar / the Alt+o switcher.
_PALETTE_FZF_COLORS = ( _PALETTE_FZF_COLORS = (
+24
View File
@@ -18,8 +18,32 @@ def _migrate_v2(conn: Connection) -> None:
conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT") conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT")
def _migrate_v3(conn: Connection) -> None:
conn.exec_driver_sql(
"""
CREATE TABLE scheduled_prompts (
id INTEGER NOT NULL PRIMARY KEY,
session_id INTEGER NOT NULL UNIQUE,
prompt TEXT NOT NULL,
due_at DATETIME NOT NULL,
status VARCHAR NOT NULL,
error TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
FOREIGN KEY(session_id) REFERENCES sessions (id)
)
"""
)
def _migrate_v4(conn: Connection) -> None:
conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN sandbox_json TEXT")
MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [ MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [
(2, _migrate_v2), (2, _migrate_v2),
(3, _migrate_v3),
(4, _migrate_v4),
] ]
BASELINE_VERSION = 1 BASELINE_VERSION = 1
+21 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import ForeignKey, String, func from sqlalchemy import ForeignKey, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -38,6 +38,7 @@ class Session(Base):
tmux_session_name: Mapped[str] = mapped_column(unique=True) tmux_session_name: Mapped[str] = mapped_column(unique=True)
harness_session_id: Mapped[str | None] = mapped_column(default=None) harness_session_id: Mapped[str | None] = mapped_column(default=None)
model: Mapped[str | None] = mapped_column(default=None) model: Mapped[str | None] = mapped_column(default=None)
sandbox_json: Mapped[str | None] = mapped_column(default=None)
worktree_path: Mapped[str | None] = mapped_column(default=None) worktree_path: Mapped[str | None] = mapped_column(default=None)
worktree_branch: Mapped[str | None] = mapped_column(default=None) worktree_branch: Mapped[str | None] = mapped_column(default=None)
created_at: Mapped[datetime] = mapped_column(insert_default=func.now()) created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
@@ -45,6 +46,25 @@ class Session(Base):
archived: Mapped[bool] = mapped_column(default=False) archived: Mapped[bool] = mapped_column(default=False)
project: Mapped["Project"] = relationship(back_populates="sessions") project: Mapped["Project"] = relationship(back_populates="sessions")
harness: Mapped["Harness"] = relationship(back_populates="sessions") harness: Mapped["Harness"] = relationship(back_populates="sessions")
scheduled_prompt: Mapped["ScheduledPrompt | None"] = relationship(
back_populates="session"
)
class ScheduledPrompt(Base):
__tablename__ = "scheduled_prompts"
id: Mapped[int] = mapped_column(primary_key=True)
session_id: Mapped[int] = mapped_column(
ForeignKey("sessions.id"),
unique=True,
)
prompt: Mapped[str] = mapped_column(Text)
due_at: Mapped[datetime]
status: Mapped[str] = mapped_column(String, default="pending")
error: Mapped[str | None] = mapped_column(Text, default=None)
created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
sent_at: Mapped[datetime | None] = mapped_column(default=None)
session: Mapped["Session"] = relationship(back_populates="scheduled_prompt")
class McpServer(Base): class McpServer(Base):
+18 -2
View File
@@ -2,6 +2,8 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from hqt.sandbox import Bind
@dataclass @dataclass
class SpawnConfig: class SpawnConfig:
@@ -14,20 +16,34 @@ class HarnessConfigurator(ABC):
name: str name: str
display_name: str display_name: str
captures_session_id: bool = False captures_session_id: bool = False
# Flags appended to the harness command when the session is sandboxed.
sandbox_skip_permission_flags: list[str] = []
@abstractmethod @abstractmethod
def generate_session_id(self, hqt_session_id: int) -> str: ... def generate_session_id(self, hqt_session_id: int) -> str: ...
@abstractmethod @abstractmethod
def build_spawn_config( def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ... ) -> SpawnConfig: ...
@abstractmethod @abstractmethod
def build_resume_config( def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ... ) -> SpawnConfig: ...
def sandbox_binds(self) -> list[Bind]:
"""Host paths this harness needs inside the jail (credentials, config)."""
return []
def capture_session_id(self, project_path: Path, since: float) -> str | None: def capture_session_id(self, project_path: Path, since: float) -> str | None:
return None return None
+19 -2
View File
@@ -2,6 +2,7 @@ import uuid
from pathlib import Path from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
from hqt.sandbox import Bind
NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890") NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
@@ -9,6 +10,7 @@ NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
class ClaudeConfigurator(HarnessConfigurator): class ClaudeConfigurator(HarnessConfigurator):
name = "claude" name = "claude"
display_name = "Claude Code" display_name = "Claude Code"
sandbox_skip_permission_flags = ["--dangerously-skip-permissions"]
# Markers for parse_status — conservative, case-insensitive substring checks. # Markers for parse_status — conservative, case-insensitive substring checks.
# None returned when none match; caller falls back to activity-based status. # None returned when none match; caller falls back to activity-based status.
@@ -21,20 +23,35 @@ class ClaudeConfigurator(HarnessConfigurator):
def generate_session_id(self, hqt_session_id: int) -> str: def generate_session_id(self, hqt_session_id: int) -> str:
return str(uuid.uuid5(NAMESPACE, str(hqt_session_id))) return str(uuid.uuid5(NAMESPACE, str(hqt_session_id)))
def sandbox_binds(self) -> list[Bind]:
return [Bind(Path.home() / ".claude", writable=True)]
def build_spawn_config( def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ) -> SpawnConfig:
cmd = ["claude", "--session-id", harness_session_id] cmd = ["claude", "--session-id", harness_session_id]
if model: if model:
cmd += ["--model", model] cmd += ["--model", model]
if sandboxed:
cmd += self.sandbox_skip_permission_flags
return SpawnConfig(command=cmd, cwd=project_path) return SpawnConfig(command=cmd, cwd=project_path)
def build_resume_config( def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ) -> SpawnConfig:
cmd = ["claude", "--resume", harness_session_id] cmd = ["claude", "--resume", harness_session_id]
if model: if model:
cmd += ["--model", model] cmd += ["--model", model]
if sandboxed:
cmd += self.sandbox_skip_permission_flags
return SpawnConfig(command=cmd, cwd=project_path) return SpawnConfig(command=cmd, cwd=project_path)
def parse_status(self, pane_text: str) -> str | None: def parse_status(self, pane_text: str) -> str | None:
+19 -2
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
from hqt.sandbox import Bind
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -12,6 +13,7 @@ class CodexConfigurator(HarnessConfigurator):
name = "codex" name = "codex"
display_name = "Codex" display_name = "Codex"
captures_session_id = True captures_session_id = True
sandbox_skip_permission_flags = ["--dangerously-bypass-approvals-and-sandbox"]
# Markers for parse_status — conservative, case-insensitive substring checks. # Markers for parse_status — conservative, case-insensitive substring checks.
_WORKING_MARKERS = ("esc to interrupt",) _WORKING_MARKERS = ("esc to interrupt",)
@@ -19,20 +21,35 @@ class CodexConfigurator(HarnessConfigurator):
def generate_session_id(self, hqt_session_id: int) -> str: def generate_session_id(self, hqt_session_id: int) -> str:
return str(hqt_session_id) return str(hqt_session_id)
def sandbox_binds(self) -> list[Bind]:
return [Bind(Path.home() / ".codex", writable=True)]
def build_spawn_config( def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ) -> SpawnConfig:
cmd = ["codex"] cmd = ["codex"]
if model: if model:
cmd += ["-m", model] cmd += ["-m", model]
if sandboxed:
cmd += self.sandbox_skip_permission_flags
return SpawnConfig(command=cmd, cwd=project_path) return SpawnConfig(command=cmd, cwd=project_path)
def build_resume_config( def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ) -> SpawnConfig:
cmd = ["codex", "resume", harness_session_id] cmd = ["codex", "resume", harness_session_id]
if model: if model:
cmd += ["-m", model] cmd += ["-m", model]
if sandboxed:
cmd += self.sandbox_skip_permission_flags
return SpawnConfig(command=cmd, cwd=project_path) return SpawnConfig(command=cmd, cwd=project_path)
def parse_status(self, pane_text: str) -> str | None: def parse_status(self, pane_text: str) -> str | None:
+10 -2
View File
@@ -14,11 +14,19 @@ class GenericConfigurator(HarnessConfigurator):
return str(hqt_session_id) return str(hqt_session_id)
def build_spawn_config( def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ) -> SpawnConfig:
return SpawnConfig(command=self._command, cwd=project_path) return SpawnConfig(command=self._command, cwd=project_path)
def build_resume_config( def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ) -> SpawnConfig:
return SpawnConfig(command=self._command, cwd=project_path) return SpawnConfig(command=self._command, cwd=project_path)
+14 -2
View File
@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
from hqt.sandbox import Bind
class KiroConfigurator(HarnessConfigurator): class KiroConfigurator(HarnessConfigurator):
@@ -10,12 +11,23 @@ class KiroConfigurator(HarnessConfigurator):
def generate_session_id(self, hqt_session_id: int) -> str: def generate_session_id(self, hqt_session_id: int) -> str:
return str(hqt_session_id) return str(hqt_session_id)
def sandbox_binds(self) -> list[Bind]:
return [Bind(Path.home() / ".kiro", writable=True)]
def build_spawn_config( def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ) -> SpawnConfig:
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path) return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
def build_resume_config( def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ) -> SpawnConfig:
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path) return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
+81
View File
@@ -0,0 +1,81 @@
import os
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Bind:
"""A host path to expose inside the sandbox (read-only unless writable)."""
src: Path
writable: bool = False
@dataclass(frozen=True)
class SandboxPolicy:
"""Per-session sandbox settings.
fs: "rw" binds the project dir writable; "ro" binds it read-only.
net: True shares the host network namespace; False = no connectivity.
"""
fs: str
net: bool
def is_available() -> bool:
"""True when bubblewrap can be used: Linux with `bwrap` on PATH.
The single source of truth for both `hqt doctor` and the New Session dialog.
"""
return sys.platform.startswith("linux") and shutil.which("bwrap") is not None
# System paths bound read-only so binaries and shared libraries resolve.
_SYSTEM_RO_PATHS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt"]
# Environment variables passed through into the jail.
_PASSTHROUGH_ENV = ["PATH", "HOME", "TERM", "LANG", "LC_ALL", "USER", "SHELL"]
def wrap(
command: list[str],
cwd: Path,
policy: SandboxPolicy,
binds: list[Bind],
) -> list[str]:
"""Build the `bwrap … -- <command>` argv for a sandboxed harness.
Minimal-allowlist model: $HOME and other user data are NOT exposed. System
dirs are read-only; the project dir (cwd) and each existing harness bind are
bound explicitly. Network is all-or-nothing: shared only when policy.net.
Binds whose source does not exist are skipped (bwrap would otherwise error).
"""
args: list[str] = ["bwrap", "--die-with-parent", "--unshare-all"]
if policy.net:
args.append("--share-net")
for path in _SYSTEM_RO_PATHS:
if Path(path).exists():
args += ["--ro-bind", path, path]
# Git reads user.name/email from ~/.gitconfig; without it `git commit` fails
# inside the jail. Bound read-only since $HOME itself is hidden.
gitconfig = Path.home() / ".gitconfig"
if gitconfig.exists():
args += ["--ro-bind", str(gitconfig), str(gitconfig)]
args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]
for bind in binds:
if not bind.src.exists():
continue
flag = "--bind" if bind.writable else "--ro-bind"
args += [flag, str(bind.src), str(bind.src)]
cwd_flag = "--bind" if policy.fs == "rw" else "--ro-bind"
args += [cwd_flag, str(cwd), str(cwd)]
for name in _PASSTHROUGH_ENV:
value = os.environ.get(name)
if value is not None:
args += ["--setenv", name, value]
args.append("--")
args += command
return args
+398 -32
View File
@@ -1,9 +1,10 @@
import asyncio import asyncio
import contextlib import contextlib
import json
import logging import logging
import shutil import shutil
import time import time
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -11,11 +12,13 @@ from pathlib import Path
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from sqlalchemy.orm import selectinload, sessionmaker from sqlalchemy.orm import selectinload, sessionmaker
from hqt.db.models import Harness, Project, Session from hqt import sandbox
from hqt.db.models import Harness, Project, ScheduledPrompt, Session
from hqt.errors import ServiceError from hqt.errors import ServiceError
from hqt.git import worktree from hqt.git import worktree
from hqt.git.worktree import WorktreeState from hqt.git.worktree import WorktreeState
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
from hqt.sandbox import Bind, SandboxPolicy
from hqt.status import window_label from hqt.status import window_label
from hqt.tools import TOOLS from hqt.tools import TOOLS
from hqt.tmux.manager import SpawnRequest, TmuxManager from hqt.tmux.manager import SpawnRequest, TmuxManager
@@ -26,6 +29,10 @@ log = logging.getLogger(__name__)
CAPTURE_MAX_ATTEMPTS = 6 CAPTURE_MAX_ATTEMPTS = 6
CAPTURE_RETRY_INTERVAL = 0.5 CAPTURE_RETRY_INTERVAL = 0.5
ACTIVITY_RECENT_SECS = 5 ACTIVITY_RECENT_SECS = 5
PROMPT_PENDING = "pending"
PROMPT_SENDING = "sending"
PROMPT_SENT = "sent"
PROMPT_FAILED = "failed"
async def _capture_sleep(t: float) -> None: async def _capture_sleep(t: float) -> None:
@@ -37,6 +44,7 @@ class SessionInfo:
session: Session session: Session
alive: bool alive: bool
status: str = "dead" status: str = "dead"
scheduled_prompt: ScheduledPrompt | None = None
@dataclass @dataclass
@@ -48,6 +56,14 @@ class CreateSessionResult:
spawn_error: str = "" spawn_error: str = ""
@dataclass
class DispatchResult:
session_id: int
window_name: str
ok: bool
error: str = ""
class SessionService: class SessionService:
"""Session lifecycle operations. """Session lifecycle operations.
@@ -161,6 +177,51 @@ class SessionService:
return created_at.timestamp() return created_at.timestamp()
return 0.0 return 0.0
def _sandbox_policy(self, sess: Session) -> SandboxPolicy | None:
"""Decode the persisted sandbox policy, or None if the session is plain."""
if not sess.sandbox_json:
return None
data = json.loads(sess.sandbox_json)
return SandboxPolicy(fs=data["fs"], net=data["net"])
def _worktree_git_binds(self, db: DBSession, sess: Session) -> list[Bind]:
"""Extra binds a sandboxed worktree session needs for git to work.
A linked worktree's `.git` is a file pointing at the real gitdir under
the repo's common `.git` directory (`<repo>/.git/worktrees/<branch>`),
which lives OUTSIDE the bound cwd. Bind the repo's `.git` writable so
git operations (status, commit) resolve inside the jail. Plain sessions
need nothing extra.
"""
if not sess.worktree_path or sess.worktree_branch is None:
return []
repo = self._repo_path_for_worktree(
db, sess.project_id, sess.worktree_path, sess.worktree_branch
)
return [Bind(repo / ".git", writable=True)]
def _wrap_command(
self,
configurator: HarnessConfigurator,
command: list[str],
cwd: Path,
policy: SandboxPolicy | None,
extra_binds: Sequence[Bind] = (),
) -> list[str]:
"""Wrap a harness command in bwrap when a policy is set.
Raises ServiceError if a sandbox is required but bwrap is unavailable
a backstop; the dialog already disables the toggle in that case.
"""
if policy is None:
return command
if not sandbox.is_available():
raise ServiceError(
"bubblewrap (bwrap) is not available; cannot start a sandboxed session"
)
binds = [*configurator.sandbox_binds(), *extra_binds]
return sandbox.wrap(command, cwd, policy, binds)
def _maybe_capture_missing_session_id(self, db: DBSession, sess: Session) -> None: def _maybe_capture_missing_session_id(self, db: DBSession, sess: Session) -> None:
configurator = self.harnesses.get(sess.harness.name) configurator = self.harnesses.get(sess.harness.name)
if configurator is None or not configurator.captures_session_id: if configurator is None or not configurator.captures_session_id:
@@ -194,6 +255,7 @@ class SessionService:
nickname: str | None = None, nickname: str | None = None,
model: str | None = None, model: str | None = None,
worktree_branch: str | None = None, worktree_branch: str | None = None,
sandbox: SandboxPolicy | None = None,
) -> "CreateSessionResult": ) -> "CreateSessionResult":
"""Create a new session row, spawn the harness window, and return a """Create a new session row, spawn the harness window, and return a
CreateSessionResult with the Session row and spawn outcome. CreateSessionResult with the Session row and spawn outcome.
@@ -240,13 +302,25 @@ class SessionService:
sess.tmux_session_name = f"hqt-{sess.id}" sess.tmux_session_name = f"hqt-{sess.id}"
harness_session_id = configurator.generate_session_id(sess.id) harness_session_id = configurator.generate_session_id(sess.id)
sess.harness_session_id = harness_session_id sess.harness_session_id = harness_session_id
sess.sandbox_json = (
json.dumps({"fs": sandbox.fs, "net": sandbox.net})
if sandbox is not None
else None
)
spawn_cfg = configurator.build_spawn_config( spawn_cfg = configurator.build_spawn_config(
run_path, harness_session_id, model run_path, harness_session_id, model, sandboxed=sandbox is not None
)
command = self._wrap_command(
configurator,
spawn_cfg.command,
spawn_cfg.cwd,
sandbox,
self._worktree_git_binds(db, sess),
) )
log.info( log.info(
"Spawning window %s: cmd=%s cwd=%s", "Spawning window %s: cmd=%s cwd=%s",
sess.tmux_session_name, sess.tmux_session_name,
spawn_cfg.command, command,
spawn_cfg.cwd, spawn_cfg.cwd,
) )
async with self._maybe_capture_lock(configurator.captures_session_id): async with self._maybe_capture_lock(configurator.captures_session_id):
@@ -254,7 +328,7 @@ class SessionService:
spawn_result = await self.tmux.spawn( spawn_result = await self.tmux.spawn(
SpawnRequest( SpawnRequest(
window_name=sess.tmux_session_name, window_name=sess.tmux_session_name,
command=spawn_cfg.command, command=command,
cwd=str(spawn_cfg.cwd), cwd=str(spawn_cfg.cwd),
env=spawn_cfg.env, env=spawn_cfg.env,
) )
@@ -292,6 +366,14 @@ class SessionService:
raise ServiceError("Session not found") raise ServiceError("Session not found")
window_name = sess.tmux_session_name window_name = sess.tmux_session_name
await self._prepare_session_for_resume(db, sess)
ok = await self._ensure_session_alive(db, sess, window_name)
if not ok:
return False
return await self.tmux.attach(window_name)
async def _prepare_session_for_resume(self, db: DBSession, sess: Session) -> None:
"""Run pre-resume repair/capture work without selecting the window."""
# Rung 0: a worktree session whose directory was removed from disk # Rung 0: a worktree session whose directory was removed from disk
# but whose branch still exists can be recreated before anything # but whose branch still exists can be recreated before anything
# else runs. If recreation fails, the branch is gone too. # else runs. If recreation fails, the branch is gone too.
@@ -311,25 +393,37 @@ class SessionService:
self._maybe_capture_missing_session_id(db, sess) self._maybe_capture_missing_session_id(db, sess)
if await self.tmux.is_alive(window_name): async def _ensure_session_alive(
# Window exists, process running — just switch self, db: DBSession, sess: Session, window_name: str
return await self.tmux.attach(window_name) ) -> bool:
"""Ensure a session's tmux pane is alive without selecting the window."""
ok, _error = await self._ensure_session_alive_result(db, sess, window_name)
return ok
# Window exists but pane is dead, OR window is completely gone async def _ensure_session_alive_result(
self, db: DBSession, sess: Session, window_name: str
) -> tuple[bool, str]:
"""Ensure a session is alive, returning the respawn error on failure."""
if await self.tmux.is_alive(window_name):
return True, ""
log.info( log.info(
"Session %s not alive, respawning with fallback ladder", window_name "Session %s not alive, respawning with fallback ladder",
window_name,
) )
ok = await self._respawn_with_fallback(db, sess, window_name) return await self._respawn_with_fallback_result(db, sess, window_name)
if not ok:
return False
return await self.tmux.attach(window_name)
async def _respawn_with_fallback( async def _respawn_with_fallback(
self, db: DBSession, sess: Session, window_name: str self, db: DBSession, sess: Session, window_name: str
) -> bool: ) -> bool:
ok, _error = await self._respawn_with_fallback_result(db, sess, window_name)
return ok
async def _respawn_with_fallback_result(
self, db: DBSession, sess: Session, window_name: str
) -> tuple[bool, str]:
"""Try resume config first; if it dies, fall back to fresh spawn config. """Try resume config first; if it dies, fall back to fresh spawn config.
Returns True if either rung succeeded, False if both failed. Returns (True, "") if either rung succeeded, otherwise (False, error).
On rung-2 success, if the harness captures_session_id, a capture-retry 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 loop updates sess.harness_session_id so later resumes target the fresh
@@ -341,7 +435,7 @@ class SessionService:
window_name, resume_cfg.command, str(resume_cfg.cwd), env=resume_cfg.env window_name, resume_cfg.command, str(resume_cfg.cwd), env=resume_cfg.env
) )
if result.ok: if result.ok:
return True return True, ""
log.warning( log.warning(
"Resume failed for %s (%s), falling back to fresh spawn", "Resume failed for %s (%s), falling back to fresh spawn",
@@ -355,21 +449,30 @@ class SessionService:
harness_session_id = ( harness_session_id = (
sess.harness_session_id or configurator.generate_session_id(sess.id) sess.harness_session_id or configurator.generate_session_id(sess.id)
) )
policy = self._sandbox_policy(sess)
spawn_cfg = configurator.build_spawn_config( spawn_cfg = configurator.build_spawn_config(
run_path, harness_session_id, sess.model run_path, harness_session_id, sess.model, sandboxed=policy is not None
)
command = self._wrap_command(
configurator,
spawn_cfg.command,
spawn_cfg.cwd,
policy,
self._worktree_git_binds(db, sess),
) )
async with self._maybe_capture_lock(configurator.captures_session_id): async with self._maybe_capture_lock(configurator.captures_session_id):
since = time.time() since = time.time()
result = await self.tmux.respawn_verified( result = await self.tmux.respawn_verified(
window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env window_name, command, str(spawn_cfg.cwd), env=spawn_cfg.env
) )
if not result.ok: if not result.ok:
error = result.error or "respawn failed"
log.error( log.error(
"Fallback spawn also failed for %s: %s", "Fallback spawn also failed for %s: %s",
window_name, window_name,
result.error or "(no output)", result.error or "(no output)",
) )
return False return False, error
# Rung-2 succeeded — capture the new session id if the harness # Rung-2 succeeded — capture the new session id if the harness
# supports it so that future resumes target this fresh conversation. # supports it so that future resumes target this fresh conversation.
@@ -386,7 +489,7 @@ class SessionService:
window_name, window_name,
sess.harness_session_id, sess.harness_session_id,
) )
return True return True, ""
def session_id_for_window(self, window_name: str) -> int | None: def session_id_for_window(self, window_name: str) -> int | None:
"""Resolve a tmux window name to its active hqt session id, or None. """Resolve a tmux window name to its active hqt session id, or None.
@@ -402,6 +505,19 @@ class SessionService:
) )
return sess.id if sess else None return sess.id if sess else None
def require_session_id_for_window(self, window_name: str) -> int:
"""Like session_id_for_window, but raise ServiceError instead of None.
The shared guard for window-keyed CLI operations (tool/stop/rename): the
`hqt tool` binding and the Alt+p palette pass #{window_name}, and a
non-session window (tool window, TUI home, unrelated tmux window) is a
clean error rather than a silent no-op.
"""
session_id = self.session_id_for_window(window_name)
if session_id is None:
raise ServiceError(f"{window_name!r} is not an hqt session window")
return session_id
async def open_tool_window(self, session_id: int, tool: str) -> str | None: async def open_tool_window(self, session_id: int, tool: str) -> str | None:
"""Open a tool (nvim/lazygit/shell) in a new styled window for a session. """Open a tool (nvim/lazygit/shell) in a new styled window for a session.
@@ -435,14 +551,10 @@ class SessionService:
Used by the `hqt tool` CLI (the tmux binding passes #{window_name}). Used by the `hqt tool` CLI (the tmux binding passes #{window_name}).
Raises ServiceError if the name is not an active hqt session window. Raises ServiceError if the name is not an active hqt session window.
""" """
session_id = self.session_id_for_window(window_name) session_id = self.require_session_id_for_window(window_name)
if session_id is None:
raise ServiceError(f"{window_name!r} is not an hqt session window")
return await self.open_tool_window(session_id, tool) return await self.open_tool_window(session_id, tool)
async def clone_session_for_window( async def clone_session_for_window(self, window_name: str) -> "CreateSessionResult":
self, window_name: str
) -> "CreateSessionResult":
"""Open a fresh harness session cloning the one in `window_name`. """Open a fresh harness session cloning the one in `window_name`.
Same project, harness, and model as the source session, but a brand-new Same project, harness, and model as the source session, but a brand-new
@@ -476,6 +588,16 @@ class SessionService:
if await self.tmux.window_exists(window_name): if await self.tmux.window_exists(window_name):
await self.tmux.kill(window_name) await self.tmux.kill(window_name)
async def stop_session_for_window(self, window_name: str) -> None:
"""Stop the harness session identified by its tmux window name.
Used by the `hqt tool stop` CLI (the Alt+p palette passes #{window_name}).
Mirrors the TUI's `s` action: resolves the window to its session and
kills the window, which tears down the harness. Raises ServiceError if
the name is not an active hqt session window.
"""
await self.stop_session(self.require_session_id_for_window(window_name))
def get_session(self, session_id: int) -> Session | None: def get_session(self, session_id: int) -> Session | None:
"""Return the session row, or None if it does not exist.""" """Return the session row, or None if it does not exist."""
with self.factory() as db: with self.factory() as db:
@@ -495,6 +617,173 @@ class SessionService:
sess.nickname = (nickname or "").strip() or None sess.nickname = (nickname or "").strip() or None
db.commit() db.commit()
def schedule_prompt(
self, session_id: int, prompt: str, due_at: datetime
) -> ScheduledPrompt:
"""Create or replace the pending scheduled prompt for a session."""
prompt = prompt.strip()
if not prompt:
raise ServiceError("Prompt cannot be empty")
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
raise ServiceError("Session not found")
scheduled = (
db.query(ScheduledPrompt).filter_by(session_id=session_id).one_or_none()
)
if scheduled is None:
scheduled = ScheduledPrompt(session_id=session_id, prompt=prompt)
db.add(scheduled)
else:
scheduled.prompt = prompt
scheduled.due_at = due_at
scheduled.status = PROMPT_PENDING
scheduled.error = None
scheduled.sent_at = None
db.commit()
db.refresh(scheduled)
db.expunge(scheduled)
return scheduled
def _mark_prompt_failed(
self, db: DBSession, row: ScheduledPrompt, error: str
) -> DispatchResult:
row.status = PROMPT_FAILED
row.error = error
db.commit()
session = db.get(Session, row.session_id)
window_name = session.tmux_session_name if session is not None else ""
return DispatchResult(
session_id=row.session_id,
window_name=window_name,
ok=False,
error=error,
)
def _dispatch_error_message(self, exc: Exception) -> str:
return str(exc) or exc.__class__.__name__
async def dispatch_due_prompts(
self, now: datetime | None = None
) -> list[DispatchResult]:
"""Dispatch due pending prompts once; failed prompts are not retried."""
if now is None:
now = datetime.now()
results: list[DispatchResult] = []
with self.factory() as db:
rows = (
db.query(ScheduledPrompt)
.filter(
ScheduledPrompt.status == PROMPT_PENDING,
ScheduledPrompt.due_at <= now,
)
.all()
)
for row in rows:
row.status = PROMPT_SENDING
row.error = None
db.commit()
sess = db.get(
Session,
row.session_id,
options=[selectinload(Session.harness)],
)
if sess is None:
results.append(
self._mark_prompt_failed(db, row, "Session not found")
)
continue
window_name = sess.tmux_session_name
try:
await self._prepare_session_for_resume(db, sess)
alive, error = await self._ensure_session_alive_result(
db, sess, window_name
)
except Exception as exc:
results.append(
self._mark_prompt_failed(
db, row, self._dispatch_error_message(exc)
)
)
continue
if not alive:
results.append(
self._mark_prompt_failed(
db,
row,
error or "Failed to resume session before prompt",
)
)
continue
try:
send_text_ok = await self.tmux.send_text(window_name, row.prompt)
except Exception as exc:
results.append(
self._mark_prompt_failed(
db, row, self._dispatch_error_message(exc)
)
)
continue
if not send_text_ok:
results.append(
self._mark_prompt_failed(db, row, "send text failed")
)
continue
try:
send_enter_ok = await self.tmux.send_enter(window_name)
except Exception as exc:
results.append(
self._mark_prompt_failed(
db, row, self._dispatch_error_message(exc)
)
)
continue
if not send_enter_ok:
results.append(
self._mark_prompt_failed(db, row, "send enter failed")
)
continue
row.status = PROMPT_SENT
row.sent_at = now
row.error = None
db.commit()
results.append(
DispatchResult(
session_id=sess.id,
window_name=window_name,
ok=True,
)
)
return results
def cancel_scheduled_prompt(self, session_id: int) -> None:
"""Delete any scheduled prompt for a session."""
with self.factory() as db:
scheduled = (
db.query(ScheduledPrompt).filter_by(session_id=session_id).one_or_none()
)
if scheduled is None:
return
db.delete(scheduled)
db.commit()
def get_scheduled_prompt(self, session_id: int) -> ScheduledPrompt | None:
"""Return a session's pending scheduled prompt, if one exists."""
with self.factory() as db:
scheduled = (
db.query(ScheduledPrompt)
.filter_by(session_id=session_id, status=PROMPT_PENDING)
.one_or_none()
)
if scheduled is None:
return None
db.expunge(scheduled)
return scheduled
async def delete_session( async def delete_session(
self, session_id: int, remove_worktree: bool = False, force: bool = False self, session_id: int, remove_worktree: bool = False, force: bool = False
) -> None: ) -> None:
@@ -529,9 +818,45 @@ class SessionService:
force, force,
) )
scheduled = (
db.query(ScheduledPrompt).filter_by(session_id=session_id).one_or_none()
)
if scheduled is not None:
db.delete(scheduled)
db.delete(sess) db.delete(sess)
db.commit() db.commit()
async def archive_session(self, session_id: int) -> None:
"""Archive a session: kill its tmux window but keep the DB row.
A recoverable "remove from view" the window/harness process is torn
down (freeing resources) but the row, ``harness_session_id``, and any
worktree are preserved so the session can be restored or referenced
later. No-op if the session does not exist.
"""
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
return
if await self.tmux.window_exists(sess.tmux_session_name):
await self.tmux.kill(sess.tmux_session_name)
sess.archived = True
db.commit()
async def unarchive_session(self, session_id: int) -> None:
"""Restore an archived session by clearing its archived flag.
The tmux window stays gone (the session reads as dead until resumed);
this only makes the row visible in the active list again. No-op if the
session does not exist.
"""
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
return
sess.archived = False
db.commit()
async def worktree_state_for(self, session_id: int) -> WorktreeState | None: async def worktree_state_for(self, session_id: int) -> WorktreeState | None:
"""Return the WorktreeState for a session's worktree, or None. """Return the WorktreeState for a session's worktree, or None.
@@ -571,8 +896,23 @@ class SessionService:
age = now - wi.last_activity age = now - wi.last_activity
return "active" if age <= ACTIVITY_RECENT_SECS else "idle" return "active" if age <= ACTIVITY_RECENT_SECS else "idle"
def _pending_prompt_map(
self, db: DBSession, session_ids: list[int]
) -> dict[int, ScheduledPrompt]:
if not session_ids:
return {}
prompts = (
db.query(ScheduledPrompt)
.filter(
ScheduledPrompt.session_id.in_(session_ids),
ScheduledPrompt.status == PROMPT_PENDING,
)
.all()
)
return {prompt.session_id: prompt for prompt in prompts}
async def list_sessions( async def list_sessions(
self, project_id: int, now: float | None = None self, project_id: int, archived: bool = False, now: float | None = None
) -> list[SessionInfo]: ) -> list[SessionInfo]:
if now is None: if now is None:
now = time.time() now = time.time()
@@ -580,18 +920,24 @@ class SessionService:
sessions = ( sessions = (
db.query(Session) db.query(Session)
.options(selectinload(Session.harness)) .options(selectinload(Session.harness))
.filter_by(project_id=project_id, archived=False) .filter_by(project_id=project_id, archived=archived)
.all() .all()
) )
names = [s.tmux_session_name for s in sessions] names = [s.tmux_session_name for s in sessions]
info_map = await self.tmux.poll_info(names) info_map = await self.tmux.poll_info(names)
prompt_map = self._pending_prompt_map(db, [s.id for s in sessions])
result: list[SessionInfo] = [] result: list[SessionInfo] = []
for s in sessions: for s in sessions:
self._maybe_capture_missing_session_id(db, s) self._maybe_capture_missing_session_id(db, s)
wi = info_map.get(s.tmux_session_name) wi = info_map.get(s.tmux_session_name)
status = await self._status_for(s, wi, now) status = await self._status_for(s, wi, now)
result.append( result.append(
SessionInfo(session=s, alive=bool(wi and wi.alive), status=status) SessionInfo(
session=s,
alive=bool(wi and wi.alive),
status=status,
scheduled_prompt=prompt_map.get(s.id),
)
) )
return result return result
@@ -615,13 +961,21 @@ class SessionService:
) )
names = [s.tmux_session_name for s in sessions] names = [s.tmux_session_name for s in sessions]
info_map = await self.tmux.poll_info(names) info_map = await self.tmux.poll_info(names)
prompt_map = self._pending_prompt_map(db, [s.id for s in sessions])
result: list[SessionInfo] = [] result: list[SessionInfo] = []
for s in sessions: for s in sessions:
self._maybe_capture_missing_session_id(db, s) self._maybe_capture_missing_session_id(db, s)
wi = info_map.get(s.tmux_session_name) wi = info_map.get(s.tmux_session_name)
alive = bool(wi and wi.alive) alive = bool(wi and wi.alive)
status = await self._status_for(s, wi, now) status = await self._status_for(s, wi, now)
result.append(SessionInfo(session=s, alive=alive, status=status)) result.append(
SessionInfo(
session=s,
alive=alive,
status=status,
scheduled_prompt=prompt_map.get(s.id),
)
)
name = s.nickname or s.tmux_session_name name = s.nickname or s.tmux_session_name
await self.tmux.set_window_label( await self.tmux.set_window_label(
s.tmux_session_name, window_label(status, name, alive=alive) s.tmux_session_name, window_label(status, name, alive=alive)
@@ -633,6 +987,18 @@ class SessionService:
harness_session_id = ( harness_session_id = (
sess.harness_session_id or configurator.generate_session_id(sess.id) sess.harness_session_id or configurator.generate_session_id(sess.id)
) )
return configurator.build_resume_config( policy = self._sandbox_policy(sess)
self._session_path(db, sess), harness_session_id, sess.model cfg = configurator.build_resume_config(
self._session_path(db, sess),
harness_session_id,
sess.model,
sandboxed=policy is not None,
) )
command = self._wrap_command(
configurator,
cfg.command,
cfg.cwd,
policy,
self._worktree_git_binds(db, sess),
)
return SpawnConfig(command=command, env=cfg.env, cwd=cfg.cwd)
+12
View File
@@ -113,10 +113,22 @@ class TmuxManager:
"""Return the visible text of the named window's pane.""" """Return the visible text of the named window's pane."""
return await self.runner.capture_pane(window_name) return await self.runner.capture_pane(window_name)
async def send_text(self, window_name: str, text: str) -> bool:
"""Send literal text to a harness window."""
return await self.runner.send_text(window_name, text)
async def send_enter(self, window_name: str) -> bool:
"""Send Enter to a harness window."""
return await self.runner.send_enter(window_name)
async def set_window_label(self, window_name: str, label: str) -> None: async def set_window_label(self, window_name: str, label: str) -> None:
"""Set the status-bar label for a window (status symbol + name).""" """Set the status-bar label for a window (status symbol + name)."""
await self.runner.set_window_label(window_name, label) await self.runner.set_window_label(window_name, label)
async def reset_window_indexes(self, home_window: str) -> bool:
"""Compact tmux window indexes while keeping the home window at 0."""
return await self.runner.reset_window_indexes(home_window)
async def open_aux_window( async def open_aux_window(
self, name: str, cwd: str, command: list[str], label: str self, name: str, cwd: str, command: list[str], label: str
) -> str | None: ) -> str | None:
+140 -6
View File
@@ -117,6 +117,15 @@ class WindowInfo:
last_activity: int last_activity: int
@dataclass(frozen=True)
class WindowIndex:
"""Stable tmux window identity plus its current numeric index."""
window_id: str
index: int
name: str
class TmuxRunner: class TmuxRunner:
"""Low-level async tmux command wrapper. All operations target windows within a single session.""" """Low-level async tmux command wrapper. All operations target windows within a single session."""
@@ -232,6 +241,104 @@ class TmuxRunner:
flags.extend(["-e", f"{key}={value}"]) flags.extend(["-e", f"{key}={value}"])
return flags return flags
async def _list_window_indexes(self) -> list[WindowIndex] | None:
"""Return window ids, indexes, and names for this tmux session.
None means tmux failed or returned malformed data; an empty list means
the session listed successfully but has no windows.
"""
rc, stdout, err = await self._exec(
"list-windows",
"-t",
self.session_name,
"-F",
"#{window_id}\t#{window_index}\t#{window_name}",
)
if rc != 0:
log.error("list-windows failed for %s: %s", self.session_name, err)
return None
windows: list[WindowIndex] = []
for line in stdout.splitlines():
if not line.strip():
continue
parts = line.split("\t", 2)
if len(parts) != 3:
log.error("Malformed list-windows row: %r", line)
return None
window_id, index_text, name = parts
try:
index = int(index_text)
except ValueError:
log.error("Malformed window index in row: %r", line)
return None
windows.append(WindowIndex(window_id=window_id, index=index, name=name))
return windows
async def _move_window_id_to_index(self, window_id: str, index: int) -> bool:
rc, _, err = await self._exec(
"move-window",
"-d",
"-s",
window_id,
"-t",
f"{self.session_name}:{index}",
)
if rc != 0:
log.error("move-window failed for %s -> %s: %s", window_id, index, err)
return False
return True
async def reset_window_indexes(self, home_window: str) -> bool:
"""Compact tmux window indexes while keeping the home window at 0.
The operation changes tmux indexes only. It preserves window names,
panes, processes, hqt database rows, and harness session ids.
"""
windows = await self._list_window_indexes()
if windows is None:
return False
home = next((window for window in windows if window.name == home_window), None)
if home is None:
log.error(
"Home window %r not found in session %s",
home_window,
self.session_name,
)
return False
non_home = sorted(
(window for window in windows if window.window_id != home.window_id),
key=lambda window: window.index,
)
desired: list[tuple[WindowIndex, int]] = [(home, 0)]
desired.extend(
(window, index) for index, window in enumerate(non_home, start=1)
)
if all(window.index == target_index for window, target_index in desired):
return True
# Move every window out of the target range before placing final indexes.
# This avoids move-window failures when a destination index is occupied.
temp_start = max(window.index for window in windows) + len(windows)
for offset, window in enumerate(
sorted(windows, key=lambda item: item.index),
start=1,
):
if not await self._move_window_id_to_index(
window.window_id,
temp_start + offset,
):
return False
for window, target_index in desired:
if not await self._move_window_id_to_index(window.window_id, target_index):
return False
return True
async def new_window( async def new_window(
self, self,
window_name: str, window_name: str,
@@ -351,8 +458,10 @@ class TmuxRunner:
return None return None
window_id = stdout.strip() window_id = stdout.strip()
# Style by window_id: automatic-rename off, the @hqt_label, then the # Style by window_id: automatic-rename + allow-rename off (nvim/lazygit
# Frappé per-window theme — one atomic invocation (";" argv separators). # emit OSC title sequences that would otherwise scramble the label), the
# @hqt_label, then the Frappé per-window theme — one atomic invocation
# (";" argv separators). Mirrors new_window's window-option setup.
rc, _, err = await self._exec( rc, _, err = await self._exec(
"set-option", "set-option",
"-w", "-w",
@@ -365,6 +474,13 @@ class TmuxRunner:
"-w", "-w",
"-t", "-t",
window_id, window_id,
"allow-rename",
"off",
";",
"set-option",
"-w",
"-t",
window_id,
"@hqt_label", "@hqt_label",
label, label,
";", ";",
@@ -550,11 +666,29 @@ class TmuxRunner:
for name in alive_map for name in alive_map
} }
async def send_text(self, window_name: str, text: str) -> None: async def send_text(self, window_name: str, text: str) -> bool:
await self._exec("send-keys", "-t", self._target(window_name), "-l", "--", text) rc, _, err = await self._exec(
"send-keys",
"-t",
self._target(window_name),
"-l",
"--",
text,
)
if rc != 0:
log.error("send_text failed for %s: %s", window_name, err)
return rc == 0
async def send_enter(self, window_name: str) -> None: async def send_enter(self, window_name: str) -> bool:
await self._exec("send-keys", "-t", self._target(window_name), "Enter") rc, _, err = await self._exec(
"send-keys",
"-t",
self._target(window_name),
"Enter",
)
if rc != 0:
log.error("send_enter failed for %s: %s", window_name, err)
return rc == 0
async def capture_pane(self, window_name: str) -> str: async def capture_pane(self, window_name: str) -> str:
_, stdout, _ = await self._exec( _, stdout, _ = await self._exec(
+113 -5
View File
@@ -1,5 +1,6 @@
import logging import logging
from collections.abc import Coroutine from collections.abc import Coroutine
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -10,6 +11,8 @@ from textual.screen import ModalScreen
from textual.theme import Theme from textual.theme import Theme
from textual.widgets import Footer from textual.widgets import Footer
from hqt import sandbox
from hqt.sandbox import SandboxPolicy
from hqt.config import get_settings from hqt.config import get_settings
from hqt.git import worktree from hqt.git import worktree
from hqt.db.engine import ensure_db, get_engine, get_session_factory from hqt.db.engine import ensure_db, get_engine, get_session_factory
@@ -23,6 +26,7 @@ from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
from hqt.tui.screens.project_form import ProjectFormScreen from hqt.tui.screens.project_form import ProjectFormScreen
from hqt.tui.screens.new_session import NewSessionScreen from hqt.tui.screens.new_session import NewSessionScreen
from hqt.tui.screens.rename_session import RenameSessionScreen from hqt.tui.screens.rename_session import RenameSessionScreen
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
from hqt.tui.widgets.project_list import ProjectList, ProjectSelected from hqt.tui.widgets.project_list import ProjectList, ProjectSelected
from hqt.tui.widgets.session_list import SessionList, SessionSelected from hqt.tui.widgets.session_list import SessionList, SessionSelected
@@ -66,10 +70,17 @@ class HqtApp(App):
Binding("a", "add_project", "Add Project"), Binding("a", "add_project", "Add Project"),
Binding("e", "edit_project", "Edit Project"), Binding("e", "edit_project", "Edit Project"),
Binding("tab", "toggle_focus", "Switch Panel"), Binding("tab", "toggle_focus", "Switch Panel"),
Binding("h", "focus_projects", "Projects"),
Binding("l", "focus_sessions", "Sessions"),
Binding("x", "archive_session", "Archive"),
Binding("A", "toggle_archived", "Show Archived"),
Binding("u", "unarchive_session", "Restore"),
Binding("d", "delete_session", "Delete Session"), Binding("d", "delete_session", "Delete Session"),
Binding("enter", "attach_session", "Attach", priority=True), Binding("enter", "attach_session", "Attach", priority=True),
Binding("r", "rename_session", "Rename"), Binding("r", "rename_session", "Rename"),
Binding("s", "stop_session", "Stop"), Binding("s", "stop_session", "Stop"),
Binding("p", "schedule_prompt", "Prompt Later"),
Binding("P", "cancel_scheduled_prompt", "Cancel Prompt"),
] ]
CSS_PATH = "styles.tcss" CSS_PATH = "styles.tcss"
@@ -82,6 +93,9 @@ class HqtApp(App):
self.register_theme(FRAPPE_THEME) self.register_theme(FRAPPE_THEME)
self.theme = "catppuccin-frappe" self.theme = "catppuccin-frappe"
self._selected_project_id: int | None = None self._selected_project_id: int | None = None
# When True, the Sessions column shows this project's archived sessions
# instead of its active ones. Persists across project switches.
self._show_archived = False
# project_id -> last selected session_id, so switching projects restores # project_id -> last selected session_id, so switching projects restores
# each project's previously highlighted session. # each project's previously highlighted session.
self._selected_session_by_project: dict[int, int] = {} self._selected_session_by_project: dict[int, int] = {}
@@ -158,11 +172,21 @@ class HqtApp(App):
# Sync every session's tmux window label (session-wide), then update the # Sync every session's tmux window label (session-wide), then update the
# UI for the selected project from the same computed status — no recompute. # UI for the selected project from the same computed status — no recompute.
try: try:
dispatch_results = await self._sessions().dispatch_due_prompts()
for result in dispatch_results:
if result.ok:
self.notify(f"Prompt sent to {result.window_name}")
else:
target = result.window_name or f"session {result.session_id}"
self.notify(
f"Prompt failed for {target}: {result.error}",
severity="error",
)
infos = await self._sessions().sync_window_labels() infos = await self._sessions().sync_window_labels()
except ServiceError as err: except ServiceError as err:
log.warning("Session poll failed: %s", err) log.warning("Session poll failed: %s", err)
return return
if self._selected_project_id is not None: if self._selected_project_id is not None and not self._show_archived:
subset = [ subset = [
i for i in infos if i.session.project_id == self._selected_project_id i for i in infos if i.session.project_id == self._selected_project_id
] ]
@@ -171,7 +195,9 @@ class HqtApp(App):
async def _refresh_sessions(self, restore_selection: bool = False) -> None: async def _refresh_sessions(self, restore_selection: bool = False) -> None:
if self._selected_project_id is None: if self._selected_project_id is None:
return return
infos = await self._sessions().list_sessions(self._selected_project_id) infos = await self._sessions().list_sessions(
self._selected_project_id, archived=self._show_archived
)
sl = self.query_one(SessionList) sl = self.query_one(SessionList)
if restore_selection: if restore_selection:
# Project switch: restore this project's remembered session, or let the # Project switch: restore this project's remembered session, or let the
@@ -197,6 +223,12 @@ class HqtApp(App):
def action_toggle_focus(self) -> None: def action_toggle_focus(self) -> None:
self.screen.focus_next() self.screen.focus_next()
def action_focus_projects(self) -> None:
self.query_one("#project-list").focus()
def action_focus_sessions(self) -> None:
self.query_one("#session-list").focus()
def action_add_project(self) -> None: def action_add_project(self) -> None:
def on_dismiss(result: tuple[str, str] | None) -> None: def on_dismiss(result: tuple[str, str] | None) -> None:
if result: if result:
@@ -255,10 +287,11 @@ class HqtApp(App):
project_path = project.path project_path = project.path
def on_dismiss( def on_dismiss(
result: tuple[str, str, str | None, str | None] | None, result: tuple[str, str, str | None, str | None, SandboxPolicy | None]
| None,
) -> None: ) -> None:
if result: if result:
harness_name, nickname, model, worktree_branch = result harness_name, nickname, model, worktree_branch, sandbox_policy = result
async def _do() -> None: async def _do() -> None:
create_result = await self._sessions().create_session( create_result = await self._sessions().create_session(
@@ -267,6 +300,7 @@ class HqtApp(App):
nickname, nickname,
model, model,
worktree_branch=worktree_branch, worktree_branch=worktree_branch,
sandbox=sandbox_policy,
) )
if not create_result.spawn_ok: if not create_result.spawn_ok:
first_line = next( first_line = next(
@@ -296,7 +330,14 @@ class HqtApp(App):
except Exception as exc: # noqa: BLE001 - keep the worker from crashing the TUI except Exception as exc: # noqa: BLE001 - keep the worker from crashing the TUI
log.warning("is_git_repo check failed for %s: %s", project_path, exc) log.warning("is_git_repo check failed for %s: %s", project_path, exc)
is_git_repo = False is_git_repo = False
self.push_screen(NewSessionScreen(harness_names, is_git_repo), on_dismiss) self.push_screen(
NewSessionScreen(
harness_names,
is_git_repo,
sandbox_available=sandbox.is_available(),
),
on_dismiss,
)
self.run_worker(_open()) self.run_worker(_open())
@@ -309,6 +350,12 @@ class HqtApp(App):
# the focused dialog widget. # the focused dialog widget.
if action == "attach_session" and isinstance(self.screen, ModalScreen): if action == "attach_session" and isinstance(self.screen, ModalScreen):
return False return False
# Archive applies only to active sessions; restore only to archived ones.
# Hide the inapplicable one so the footer never advertises it.
if action == "archive_session" and self._show_archived:
return None
if action == "unarchive_session" and not self._show_archived:
return None
return True return True
def action_attach_session(self) -> None: def action_attach_session(self) -> None:
@@ -402,3 +449,64 @@ class HqtApp(App):
await self._refresh_sessions() await self._refresh_sessions()
self._run_service_worker(_do()) self._run_service_worker(_do())
def action_archive_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if not sid:
return
async def _do() -> None:
await self._sessions().archive_session(sid)
await self._refresh_sessions()
self._run_service_worker(_do())
def action_unarchive_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if not sid:
return
async def _do() -> None:
await self._sessions().unarchive_session(sid)
await self._refresh_sessions()
self._run_service_worker(_do())
def action_toggle_archived(self) -> None:
self._show_archived = not self._show_archived
self.query_one(SessionList).set_mode(archived=self._show_archived)
self._run_service_worker(self._refresh_sessions(restore_selection=True))
def action_schedule_prompt(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid is None:
self.notify("Select a session first", severity="warning")
return
def on_dismiss(result) -> None:
if result is None:
return
prompt, delay = result
due_at = datetime.now() + delay
try:
self._sessions().schedule_prompt(sid, prompt, due_at)
except ServiceError as err:
self.notify(str(err), severity="error")
return
self.notify(f"Prompt scheduled for session {sid} at {due_at:%H:%M}")
self._run_service_worker(self._refresh_sessions())
self.push_screen(SchedulePromptScreen(), on_dismiss)
def action_cancel_scheduled_prompt(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid is None:
self.notify("Select a session first", severity="warning")
return
try:
self._sessions().cancel_scheduled_prompt(sid)
except ServiceError as err:
self.notify(str(err), severity="error")
return
self.notify(f"Scheduled prompt canceled for session {sid}")
self._run_service_worker(self._refresh_sessions())
+72 -4
View File
@@ -1,17 +1,43 @@
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import Button, Checkbox, Input, Label, Select from textual.widgets import Button, Checkbox, Input, Label, Select, Switch
from hqt.git import worktree from hqt.git import worktree
from hqt.sandbox import SandboxPolicy
class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | None]): class NewSessionScreen(
def __init__(self, harness_names: list[str], is_git_repo: bool) -> None: ModalScreen[tuple[str, str, str | None, str | None, SandboxPolicy | None] | None]
):
# Soft vim-style field navigation: j/k walk the dialog's focus chain. Inputs
# consume printable keys first, so j/k type literally while editing text and
# only move focus when a toggle/select/button is focused.
BINDINGS = [
Binding("j", "focus_next", "Next field", show=False),
Binding("k", "focus_previous", "Prev field", show=False),
]
def __init__(
self,
harness_names: list[str],
is_git_repo: bool = False,
sandbox_available: bool = False,
) -> None:
self.harness_names = harness_names self.harness_names = harness_names
self._is_git_repo = is_git_repo self._is_git_repo = is_git_repo
self.sandbox_available = sandbox_available
super().__init__() super().__init__()
# focus_next/focus_previous are screen methods, not binding actions, so the
# j/k bindings need these thin action_* wrappers to dispatch to them.
def action_focus_next(self) -> None:
self.focus_next()
def action_focus_previous(self) -> None:
self.focus_previous()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(id="new-session-dialog"): with Vertical(id="new-session-dialog"):
yield Label("New Session") yield Label("New Session")
@@ -43,6 +69,31 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non
branch_input = Input(placeholder="branch name", id="branch-input") branch_input = Input(placeholder="branch name", id="branch-input")
branch_input.display = False branch_input.display = False
yield branch_input yield branch_input
yield Label("Sandboxed (bubblewrap):")
yield Switch(
id="sandbox-switch",
value=False,
disabled=not self.sandbox_available,
)
if not self.sandbox_available:
yield Label(
"bubblewrap not found — run `hqt doctor`",
id="sandbox-warning",
)
# Sub-options of the sandbox switch: only meaningful when sandboxing
# is on, so hidden until then (mirrors the worktree -> branch reveal).
sandbox_options = Vertical(id="sandbox-options")
sandbox_options.display = False
with sandbox_options:
yield Label("Filesystem access:")
yield Select(
[("Read-write", "rw"), ("Read-only", "ro")],
id="fs-select",
value="rw",
allow_blank=False,
)
yield Label("Network:")
yield Switch(id="net-switch", value=True)
with Horizontal(classes="dialog-actions"): with Horizontal(classes="dialog-actions"):
yield Button("OK", variant="primary", id="ok-btn") yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", id="cancel-btn") yield Button("Cancel", id="cancel-btn")
@@ -60,6 +111,11 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non
else: else:
branch_input.display = False branch_input.display = False
def on_switch_changed(self, event: Switch.Changed) -> None:
if event.switch.id != "sandbox-switch":
return
self.query_one("#sandbox-options", Vertical).display = event.value
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn": if event.button.id == "ok-btn":
self._submit() self._submit()
@@ -71,6 +127,13 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non
# can be completed from the keyboard without reaching for the mouse. # can be completed from the keyboard without reaching for the mouse.
self._submit() self._submit()
@staticmethod
def _policy_from(enabled: bool, fs: str, net: bool) -> SandboxPolicy | None:
"""Pure mapping from widget values to a policy (None when disabled)."""
if not enabled:
return None
return SandboxPolicy(fs=fs, net=net)
def _submit(self) -> None: def _submit(self) -> None:
harness = self.query_one("#harness-select", Select).value harness = self.query_one("#harness-select", Select).value
nickname = self.query_one("#nickname-input", Input).value nickname = self.query_one("#nickname-input", Input).value
@@ -86,7 +149,12 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non
branch_input.focus() branch_input.focus()
return return
enabled = self.query_one("#sandbox-switch", Switch).value
fs = self.query_one("#fs-select", Select).value
net = self.query_one("#net-switch", Switch).value
policy = self._policy_from(bool(enabled), str(fs), bool(net))
if harness and harness != Select.BLANK: if harness and harness != Select.BLANK:
self.dismiss((str(harness), nickname, model, worktree_branch)) self.dismiss((str(harness), nickname, model, worktree_branch, policy))
else: else:
self.dismiss(None) self.dismiss(None)
+71
View File
@@ -0,0 +1,71 @@
import re
from datetime import timedelta
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
_DELAY_RE = re.compile(
r"^(?:(?P<hours>\d+)h)?(?:(?P<minutes>\d+)m)?(?:(?P<seconds>\d+)s)?$"
)
def parse_delay(value: str) -> timedelta:
match = _DELAY_RE.fullmatch(value)
if match is None or not value:
raise ValueError("invalid delay")
total_seconds = (
int(match.group("hours") or 0) * 3600
+ int(match.group("minutes") or 0) * 60
+ int(match.group("seconds") or 0)
)
if total_seconds <= 0:
raise ValueError("invalid delay")
return timedelta(seconds=total_seconds)
class SchedulePromptScreen(ModalScreen[tuple[str, timedelta] | None]):
def compose(self) -> ComposeResult:
with Vertical(id="schedule-prompt-dialog"):
yield Label("Schedule Prompt")
yield Label("Prompt:")
yield Input(placeholder="prompt to send later", id="prompt-input")
yield Label("Delay:")
yield Input(placeholder="30m", id="delay-input")
yield Label("", id="schedule-error")
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:
self._submit()
def _submit(self) -> None:
prompt_input = self.query_one("#prompt-input", Input)
prompt = prompt_input.value.strip()
if not prompt:
self._set_error("Prompt cannot be empty")
prompt_input.focus()
return
delay_input = self.query_one("#delay-input", Input)
try:
delay = parse_delay(delay_input.value.strip())
except ValueError:
self._set_error("Use a delay like 90s, 30m, 2h, or 1h30m")
delay_input.focus()
return
self.dismiss((prompt, delay))
def _set_error(self, message: str) -> None:
self.query_one("#schedule-error", Label).update(message)
+19 -2
View File
@@ -23,11 +23,11 @@ SessionList {
background: $background; background: $background;
} }
ProjectFormScreen, NewSessionScreen { ProjectFormScreen, NewSessionScreen, SchedulePromptScreen {
align: center middle; align: center middle;
} }
#project-form-dialog, #new-session-dialog { #project-form-dialog, #new-session-dialog, #schedule-prompt-dialog {
width: 60; width: 60;
height: auto; height: auto;
padding: 1 2; padding: 1 2;
@@ -35,6 +35,11 @@ ProjectFormScreen, NewSessionScreen {
border: solid $border; border: solid $border;
} }
#schedule-error {
color: $error;
height: 1;
}
/* Action row: flat, single-row buttons in a centered row. Textual's default /* 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" 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 that reads as heavy and dated. Dropping the bevel (border: none) collapses
@@ -85,3 +90,15 @@ ProjectFormScreen, NewSessionScreen {
background: $surface-lighten-1; background: $surface-lighten-1;
color: $foreground; color: $foreground;
} }
/* Unify the focus highlight across the New Session dialog. The buttons get a
Peach $accent focus highlight (above), but Switch/Checkbox/Select/Input
otherwise fall back to Textual's default Lavender $border focus border, which
reads as off-palette next to the buttons. Match `tall` so only the color
changes, not the control height. */
#new-session-dialog Switch:focus,
#new-session-dialog Checkbox:focus,
#new-session-dialog Select:focus,
#new-session-dialog Input:focus {
border: tall $accent;
}
+24 -1
View File
@@ -1,3 +1,6 @@
from datetime import datetime
from math import ceil
from rich.markup import escape from rich.markup import escape
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.message import Message from textual.message import Message
@@ -28,12 +31,23 @@ _STATUS_COLORS: dict[str, str] = {
} }
def format_scheduled_prompt(due_at: datetime, now: datetime | None = None) -> str:
if now is None:
now = datetime.now()
seconds = max(0, (due_at - now).total_seconds())
if seconds < 3600:
return f"@ {max(1, ceil(seconds / 60))}m"
return f"@ {due_at.strftime('%H:%M')}"
def format_session_text( def format_session_text(
nickname: str, nickname: str,
harness_name: str, harness_name: str,
status_text: str, status_text: str,
symbol: str, symbol: str,
worktree_branch: str | None = None, worktree_branch: str | None = None,
scheduled_prompt=None,
now: datetime | None = None,
) -> str: ) -> str:
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
name = nickname name = nickname
@@ -47,7 +61,10 @@ def format_session_text(
else: else:
name = f"{nickname}{worktree_branch}" name = f"{nickname}{worktree_branch}"
label = escape(f"{name} [{harness_name}]") label = escape(f"{name} [{harness_name}]")
return f"[{color}]{symbol}[/] {label} {status_text}" schedule = ""
if scheduled_prompt is not None:
schedule = f" {format_scheduled_prompt(scheduled_prompt.due_at, now=now)}"
return f"[{color}]{symbol}[/] {label} {status_text}{schedule}"
class SessionList(Widget, can_focus=False): class SessionList(Widget, can_focus=False):
@@ -55,6 +72,11 @@ class SessionList(Widget, can_focus=False):
yield Label("Sessions", id="session-header") yield Label("Sessions", id="session-header")
yield VimListView(id="session-list") yield VimListView(id="session-list")
def set_mode(self, archived: bool) -> None:
"""Swap the column header to reflect whether archived sessions are shown."""
header = self.query_one("#session-header", Label)
header.update("Sessions (archived)" if archived else "Sessions")
async def refresh_sessions( async def refresh_sessions(
self, session_infos: list, select_session_id=_UNSET self, session_infos: list, select_session_id=_UNSET
) -> None: ) -> None:
@@ -71,6 +93,7 @@ class SessionList(Widget, can_focus=False):
status_text, status_text,
symbol, symbol,
getattr(s, "worktree_branch", None), getattr(s, "worktree_branch", None),
scheduled_prompt=getattr(info, "scheduled_prompt", None),
) )
new_items.append((f"sess-{s.id}", text, s.id)) new_items.append((f"sess-{s.id}", text, s.id))
+165 -1
View File
@@ -1,9 +1,39 @@
from unittest.mock import patch
from click.testing import CliRunner from click.testing import CliRunner
from hqt import cli from hqt import cli, sandbox
from hqt.config import Settings from hqt.config import Settings
def _which(found: set[str]):
return lambda name: f"/usr/bin/{name}" if name in found else None
def test_doctor_reports_bwrap_present():
"""Test that doctor reports bubblewrap when present."""
# tmux is discovered via cli.shutil.which; bwrap availability comes from the
# shared sandbox.is_available() helper (the single source of truth).
with (
patch.object(cli.shutil, "which", side_effect=_which({"tmux", "bwrap"})),
patch.object(sandbox, "is_available", return_value=True),
):
result = CliRunner().invoke(cli.main, ["doctor"])
assert result.exit_code == 0
assert "bubblewrap: ✓" in result.output
def test_doctor_reports_bwrap_missing():
"""Test that doctor reports bubblewrap as missing when not present."""
with (
patch.object(cli.shutil, "which", side_effect=_which({"tmux"})),
patch.object(sandbox, "is_available", return_value=False),
):
result = CliRunner().invoke(cli.main, ["doctor"])
assert result.exit_code == 0
assert "bubblewrap: ✗" in result.output
def test_tool_cmd_opens_tool_window(monkeypatch, tmp_path): def test_tool_cmd_opens_tool_window(monkeypatch, tmp_path):
monkeypatch.setattr( monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db") "hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
@@ -45,6 +75,137 @@ def test_tool_cmd_clone_dispatches_to_clone(monkeypatch, tmp_path):
assert calls["clone"] == "hqt-5" assert calls["clone"] == "hqt-5"
def test_tool_cmd_stop_dispatches_to_stop(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
calls = {}
async def fake_stop(self, window):
calls["stop"] = window
monkeypatch.setattr(
"hqt.sessions.service.SessionService.stop_session_for_window", fake_stop
)
result = CliRunner().invoke(cli.main, ["tool", "stop", "hqt-5"])
assert result.exit_code == 0, result.output
assert calls["stop"] == "hqt-5"
def test_tool_cmd_rename_applies_new_nickname(monkeypatch, tmp_path):
from types import SimpleNamespace
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.session_id_for_window",
lambda self, window: 5,
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.get_session",
lambda self, sid: SimpleNamespace(nickname="old"),
)
# Prefill the prompt with the current nickname; user edits to "new".
seen = {}
def fake_read(prompt, prefill=""):
seen["prefill"] = prefill
return "new"
monkeypatch.setattr(cli, "_read_line_from_tty", fake_read)
calls = {}
monkeypatch.setattr(
"hqt.sessions.service.SessionService.rename_session",
lambda self, sid, nickname: calls.setdefault("args", (sid, nickname)),
)
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
assert result.exit_code == 0, result.output
assert seen["prefill"] == "old"
assert calls["args"] == (5, "new")
def test_tool_cmd_rename_aborted_leaves_name(monkeypatch, tmp_path):
from types import SimpleNamespace
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.session_id_for_window",
lambda self, window: 5,
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.get_session",
lambda self, sid: SimpleNamespace(nickname="old"),
)
monkeypatch.setattr(cli, "_read_line_from_tty", lambda prompt, prefill="": None)
calls = {}
monkeypatch.setattr(
"hqt.sessions.service.SessionService.rename_session",
lambda self, sid, nickname: calls.setdefault("args", (sid, nickname)),
)
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
assert result.exit_code == 0, result.output
assert "args" not in calls # None return = aborted, no DB write
def test_tool_cmd_rename_unknown_window_raises(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.session_id_for_window",
lambda self, window: None,
)
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
assert result.exit_code == 1
assert "not an hqt session window" in result.output
def test_tool_cmd_reset_windows_dispatches_to_manager(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
calls = {}
async def fake_reset(self, home_window):
calls["home"] = home_window
return True
monkeypatch.setattr("hqt.tmux.manager.TmuxManager.reset_window_indexes", fake_reset)
result = CliRunner().invoke(cli.main, ["tool", "reset-windows", "hqt-5"])
assert result.exit_code == 0, result.output
# Compacts against the configured home/TUI window, not the trigger window.
assert calls["home"] == Settings(db_path=tmp_path / "t.db").tui_window_name
def test_tool_cmd_reset_windows_failure_reports_error(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
async def fake_reset(self, home_window):
return False
monkeypatch.setattr("hqt.tmux.manager.TmuxManager.reset_window_indexes", fake_reset)
result = CliRunner().invoke(cli.main, ["tool", "reset-windows", "hqt-5"])
assert result.exit_code == 1
assert "reset window indexes" in result.output
def test_tool_cmd_reports_service_error(monkeypatch, tmp_path): def test_tool_cmd_reports_service_error(monkeypatch, tmp_path):
from hqt.errors import ServiceError from hqt.errors import ServiceError
@@ -85,6 +246,9 @@ def test_palette_cmd_shows_popup_for_session_window(monkeypatch, tmp_path):
popup_cmd = argv[-1] popup_cmd = argv[-1]
assert "fzf" in popup_cmd assert "fzf" in popup_cmd
assert "nvim" in popup_cmd and "clone" in popup_cmd assert "nvim" in popup_cmd and "clone" in popup_cmd
assert "stop" in popup_cmd
assert "rename" in popup_cmd
assert "reset-windows" in popup_cmd
# the window is baked into the command for `hqt tool {} <window>` # the window is baked into the command for `hqt tool {} <window>`
assert "hqt-5" in popup_cmd assert "hqt-5" in popup_cmd
+137 -11
View File
@@ -95,8 +95,10 @@ def test_unversioned_existing_db_stamped_latest(tmp_path):
engine = get_engine(settings) engine = get_engine(settings)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
with engine.begin() as conn: with engine.begin() as conn:
conn.exec_driver_sql("DROP TABLE scheduled_prompts")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path") conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch") conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN sandbox_json")
migrate(engine) migrate(engine)
assert _user_version(engine) == migrations.LATEST_VERSION assert _user_version(engine) == migrations.LATEST_VERSION
@@ -113,25 +115,26 @@ def test_db_from_newer_hqt_raises(tmp_path):
def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch): def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch):
settings = _tmp_settings(tmp_path) settings = _tmp_settings(tmp_path)
ensure_db(settings) # stamps migrations.LATEST_VERSION (currently 2) ensure_db(settings) # stamps migrations.LATEST_VERSION
applied: list[int] = [] applied: list[int] = []
# Use versions above current LATEST_VERSION so none have been applied yet. # Use versions above current LATEST_VERSION so none have been applied yet.
next_version = migrations.LATEST_VERSION + 1
monkeypatch.setattr( monkeypatch.setattr(
migrations, migrations,
"MIGRATIONS", "MIGRATIONS",
[ [
*migrations.MIGRATIONS, *migrations.MIGRATIONS,
(3, lambda conn: applied.append(3)), (next_version, lambda conn: applied.append(next_version)),
(4, lambda conn: applied.append(4)), (next_version + 1, lambda conn: applied.append(next_version + 1)),
], ],
) )
monkeypatch.setattr(migrations, "LATEST_VERSION", 4) monkeypatch.setattr(migrations, "LATEST_VERSION", next_version + 1)
engine = get_engine(settings) engine = get_engine(settings)
migrate(engine) migrate(engine)
assert applied == [3, 4] assert applied == [next_version, next_version + 1]
assert _user_version(engine) == 4 assert _user_version(engine) == next_version + 1
migrate(engine) # re-run is a no-op migrate(engine) # re-run is a no-op
assert applied == [3, 4] assert applied == [next_version, next_version + 1]
def _session_column_names(engine) -> list[str]: def _session_column_names(engine) -> list[str]:
@@ -140,6 +143,12 @@ def _session_column_names(engine) -> list[str]:
return [row[1] for row in rows] return [row[1] for row in rows]
def _scheduled_prompt_column_names(engine) -> list[str]:
with engine.connect() as conn:
rows = conn.exec_driver_sql("PRAGMA table_info(scheduled_prompts)").fetchall()
return [row[1] for row in rows]
def test_fresh_db_has_worktree_columns(tmp_path): def test_fresh_db_has_worktree_columns(tmp_path):
settings = _tmp_settings(tmp_path) settings = _tmp_settings(tmp_path)
ensure_db(settings) ensure_db(settings)
@@ -147,18 +156,21 @@ def test_fresh_db_has_worktree_columns(tmp_path):
cols = _session_column_names(engine) cols = _session_column_names(engine)
assert "worktree_path" in cols assert "worktree_path" in cols
assert "worktree_branch" in cols assert "worktree_branch" in cols
assert _user_version(engine) == 2 assert "sandbox_json" in cols
assert _user_version(engine) == migrations.LATEST_VERSION
def test_v1_db_upgraded_to_v2(tmp_path): def test_v1_db_upgraded_to_latest(tmp_path):
# Build a v1 database: create all tables, drop the two new columns, stamp v1. # Build a v1 database: create all tables, drop v2+ objects, stamp v1.
settings = _tmp_settings(tmp_path) settings = _tmp_settings(tmp_path)
settings.db_path.parent.mkdir(parents=True, exist_ok=True) settings.db_path.parent.mkdir(parents=True, exist_ok=True)
engine = get_engine(settings) engine = get_engine(settings)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
with engine.begin() as conn: with engine.begin() as conn:
conn.exec_driver_sql("DROP TABLE scheduled_prompts")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path") conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch") conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN sandbox_json")
conn.exec_driver_sql("PRAGMA user_version = 1") conn.exec_driver_sql("PRAGMA user_version = 1")
migrate(engine) migrate(engine)
@@ -166,7 +178,79 @@ def test_v1_db_upgraded_to_v2(tmp_path):
cols = _session_column_names(engine) cols = _session_column_names(engine)
assert "worktree_path" in cols assert "worktree_path" in cols
assert "worktree_branch" in cols assert "worktree_branch" in cols
assert _user_version(engine) == 2 assert "sandbox_json" in cols
assert "scheduled_prompts" in inspect(engine).get_table_names()
assert _user_version(engine) == migrations.LATEST_VERSION
def test_fresh_db_has_scheduled_prompts_table(tmp_path):
settings = _tmp_settings(tmp_path)
ensure_db(settings)
engine = get_engine(settings)
tables = inspect(engine).get_table_names()
assert "scheduled_prompts" in tables
cols = _scheduled_prompt_column_names(engine)
assert cols == [
"id",
"session_id",
"prompt",
"due_at",
"status",
"error",
"created_at",
"sent_at",
]
assert _user_version(engine) == migrations.LATEST_VERSION
def test_v2_db_upgraded_to_latest_adds_scheduled_prompts_and_sandbox(tmp_path):
settings = _tmp_settings(tmp_path)
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
engine = get_engine(settings)
Base.metadata.create_all(engine)
with engine.begin() as conn:
conn.exec_driver_sql("DROP TABLE scheduled_prompts")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN sandbox_json")
conn.exec_driver_sql("PRAGMA user_version = 2")
migrate(engine)
assert "scheduled_prompts" in inspect(engine).get_table_names()
assert "sandbox_json" in _session_column_names(engine)
assert _user_version(engine) == migrations.LATEST_VERSION
def test_scheduled_prompt_one_row_per_session(tmp_path):
from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError
from hqt.db.models import ScheduledPrompt
settings = _tmp_settings(tmp_path)
ensure_db(settings)
factory = get_session_factory(get_engine(settings))
with factory() as session:
p = Project(name="sched-proj", path="/tmp/sched-proj")
h = Harness(name="claude-code-scheduled")
session.add_all([p, h])
session.flush()
s = Session(
project_id=p.id,
harness_id=h.id,
tmux_session_name="hqt-scheduled",
)
session.add(s)
session.flush()
due = datetime.now() + timedelta(minutes=30)
session.add(ScheduledPrompt(session_id=s.id, prompt="continue", due_at=due))
session.commit()
session.add(ScheduledPrompt(session_id=s.id, prompt="again", due_at=due))
with pytest.raises(IntegrityError):
session.commit()
def test_session_worktree_fields_default_none(tmp_path): def test_session_worktree_fields_default_none(tmp_path):
@@ -215,3 +299,45 @@ def test_rows_stay_readable_after_session_closes(tmp_path):
session.add(p) session.add(p)
session.commit() session.commit()
assert p.name == "x" # would raise DetachedInstanceError without the flag assert p.name == "x" # would raise DetachedInstanceError without the flag
def test_latest_version_is_four():
assert migrations.LATEST_VERSION == 4
def test_migration_adds_sandbox_json_column(tmp_path):
# Simulate a v3 DB whose sessions table predates the sandbox column.
settings = _tmp_settings(tmp_path)
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
engine = get_engine(settings)
with engine.begin() as conn:
conn.exec_driver_sql(
"CREATE TABLE sessions ("
"id INTEGER PRIMARY KEY, project_id INTEGER, harness_id INTEGER, "
"tmux_session_name TEXT)"
)
conn.exec_driver_sql("PRAGMA user_version = 3")
migrate(engine)
cols = [c["name"] for c in inspect(engine).get_columns("sessions")]
assert "sandbox_json" in cols
assert _user_version(engine) == migrations.LATEST_VERSION
def test_session_round_trips_sandbox_json(tmp_path):
settings = _tmp_settings(tmp_path)
ensure_db(settings)
factory = get_session_factory(get_engine(settings))
with factory() as session:
p = Project(name="proj", path="/tmp/proj-sandbox")
h = Harness(name="claude-sandbox")
session.add_all([p, h])
session.commit()
s = Session(
project_id=p.id,
harness_id=h.id,
tmux_session_name="hqt-9",
sandbox_json='{"fs": "rw", "net": true}',
)
session.add(s)
session.commit()
assert session.get(Session, s.id).sandbox_json == '{"fs": "rw", "net": true}'
+57 -5
View File
@@ -4,8 +4,11 @@ import time
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from hqt.harnesses.configurators.codex import CodexConfigurator from hqt.harnesses.base import HarnessConfigurator
from hqt.harnesses.configurators.claude import ClaudeConfigurator from hqt.harnesses.configurators.claude import ClaudeConfigurator
from hqt.harnesses.configurators.codex import CodexConfigurator
from hqt.harnesses.configurators.generic import GenericConfigurator
from hqt.harnesses.configurators.kiro import KiroConfigurator
from hqt.harnesses.registry import discover_harnesses from hqt.harnesses.registry import discover_harnesses
@@ -129,8 +132,6 @@ def test_codex_captures_session_id_flag():
def test_base_captures_session_id_flag_false(): def test_base_captures_session_id_flag_false():
"""HarnessConfigurator base class default must be False.""" """HarnessConfigurator base class default must be False."""
from hqt.harnesses.base import HarnessConfigurator
assert HarnessConfigurator.captures_session_id is False assert HarnessConfigurator.captures_session_id is False
@@ -287,8 +288,6 @@ CODEX_IDLE_SCREEN = """\
def test_base_parse_status_returns_none(): def test_base_parse_status_returns_none():
"""Base HarnessConfigurator.parse_status always returns None.""" """Base HarnessConfigurator.parse_status always returns None."""
from hqt.harnesses.configurators.kiro import KiroConfigurator
k = KiroConfigurator() k = KiroConfigurator()
assert k.parse_status("anything") is None assert k.parse_status("anything") is None
assert k.parse_status("") is None assert k.parse_status("") is None
@@ -346,3 +345,56 @@ def test_codex_parse_status_none_for_empty():
"""CodexConfigurator: empty text → None.""" """CodexConfigurator: empty text → None."""
c = CodexConfigurator() c = CodexConfigurator()
assert c.parse_status("") is None assert c.parse_status("") is None
# ---------------------------------------------------------------------------
# Task 3: sandbox skip-permission flags and binds
# ---------------------------------------------------------------------------
def test_base_skip_flags_default_empty():
assert HarnessConfigurator.sandbox_skip_permission_flags == []
def test_base_sandbox_binds_default_empty():
# GenericConfigurator does not override sandbox_binds; verifies the base default.
assert GenericConfigurator(["mytool"]).sandbox_binds() == []
def test_claude_skip_flag_added_when_sandboxed():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_spawn_config(Path("/tmp"), sid, sandboxed=True)
assert "--dangerously-skip-permissions" in cfg.command
def test_claude_skip_flag_absent_when_not_sandboxed():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_spawn_config(Path("/tmp"), sid, sandboxed=False)
assert "--dangerously-skip-permissions" not in cfg.command
def test_claude_resume_skip_flag_when_sandboxed():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_resume_config(Path("/tmp"), sid, sandboxed=True)
assert "--dangerously-skip-permissions" in cfg.command
def test_claude_sandbox_binds_includes_dot_claude():
c = ClaudeConfigurator()
binds = c.sandbox_binds()
assert any(b.src == Path.home() / ".claude" and b.writable for b in binds)
def test_codex_skip_flag_added_when_sandboxed():
c = CodexConfigurator()
cfg = c.build_spawn_config(Path("/tmp"), "1", sandboxed=True)
assert "--dangerously-bypass-approvals-and-sandbox" in cfg.command
def test_generic_sandboxed_is_noop_flagwise():
g = GenericConfigurator(["mytool"])
cfg = g.build_spawn_config(Path("/tmp"), "1", sandboxed=True)
assert cfg.command == ["mytool"]
+123
View File
@@ -0,0 +1,123 @@
from pathlib import Path
from hqt import sandbox
from hqt.sandbox import Bind, SandboxPolicy
def test_bind_defaults_readonly():
b = Bind(Path("/home/u/.claude"))
assert b.src == Path("/home/u/.claude")
assert b.writable is False
def test_sandbox_policy_fields():
p = SandboxPolicy(fs="rw", net=True)
assert p.fs == "rw"
assert p.net is True
def test_is_available_true_on_linux_with_bwrap(monkeypatch):
monkeypatch.setattr(sandbox.sys, "platform", "linux")
monkeypatch.setattr(sandbox.shutil, "which", lambda name: "/usr/bin/bwrap")
assert sandbox.is_available() is True
def test_is_available_false_without_bwrap(monkeypatch):
monkeypatch.setattr(sandbox.sys, "platform", "linux")
monkeypatch.setattr(sandbox.shutil, "which", lambda name: None)
assert sandbox.is_available() is False
def test_is_available_false_off_linux(monkeypatch):
monkeypatch.setattr(sandbox.sys, "platform", "darwin")
monkeypatch.setattr(sandbox.shutil, "which", lambda name: "/usr/bin/bwrap")
assert sandbox.is_available() is False
def _split(args, flag):
"""Return list of (src, dst) pairs that follow each occurrence of flag."""
out = []
for i, a in enumerate(args):
if a == flag:
out.append((args[i + 1], args[i + 2]))
return out
def test_wrap_command_after_double_dash(tmp_path):
cmd = ["claude", "--session-id", "x"]
args = sandbox.wrap(cmd, tmp_path, SandboxPolicy(fs="rw", net=True), [])
assert args[0] == "bwrap"
assert "--" in args
assert args[args.index("--") + 1 :] == cmd
def test_wrap_base_flags(tmp_path):
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
assert "--die-with-parent" in args
assert "--unshare-all" in args
assert "--proc" in args and "--dev" in args
def test_wrap_net_on_shares_net(tmp_path):
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
assert "--share-net" in args
def test_wrap_net_off_does_not_share(tmp_path):
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=False), [])
assert "--share-net" not in args
def test_wrap_cwd_rw_is_bind(tmp_path):
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
assert (str(tmp_path), str(tmp_path)) in _split(args, "--bind")
def test_wrap_cwd_ro_is_ro_bind(tmp_path):
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="ro", net=True), [])
assert (str(tmp_path), str(tmp_path)) in _split(args, "--ro-bind")
def test_wrap_writable_bind_spliced(tmp_path):
cred = tmp_path / "cred"
cred.mkdir()
args = sandbox.wrap(
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(cred, writable=True)]
)
assert (str(cred), str(cred)) in _split(args, "--bind")
def test_wrap_readonly_bind_spliced(tmp_path):
cred = tmp_path / "cred"
cred.mkdir()
args = sandbox.wrap(
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(cred)]
)
assert (str(cred), str(cred)) in _split(args, "--ro-bind")
def test_wrap_skips_missing_binds(tmp_path):
missing = tmp_path / "nope"
args = sandbox.wrap(
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(missing)]
)
assert str(missing) not in args
def test_wrap_binds_gitconfig_readonly_when_present(tmp_path, monkeypatch):
"""~/.gitconfig is bound read-only so `git commit` works inside the jail."""
home = tmp_path / "home"
home.mkdir()
gitconfig = home / ".gitconfig"
gitconfig.write_text("[user]\n\tname = Test\n")
monkeypatch.setattr(sandbox.Path, "home", classmethod(lambda cls: home))
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
assert (str(gitconfig), str(gitconfig)) in _split(args, "--ro-bind")
def test_wrap_skips_gitconfig_when_absent(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(sandbox.Path, "home", classmethod(lambda cls: home))
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
assert str(home / ".gitconfig") not in args
+581 -7
View File
@@ -1,4 +1,5 @@
import pytest import pytest
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -6,8 +7,9 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from hqt.db.models import Base, Harness, Project from hqt.db.models import Base, Harness, Project, ScheduledPrompt, Session
from hqt.errors import ServiceError from hqt.errors import ServiceError
from hqt.sandbox import SandboxPolicy
from hqt.sessions.service import SessionInfo, SessionService from hqt.sessions.service import SessionInfo, SessionService
from hqt.tmux.manager import SpawnResult, TmuxManager from hqt.tmux.manager import SpawnResult, TmuxManager
@@ -43,6 +45,8 @@ def tmux():
m.poll_info = AsyncMock(return_value={}) m.poll_info = AsyncMock(return_value={})
m.capture_pane = AsyncMock(return_value="") m.capture_pane = AsyncMock(return_value="")
m.attach = AsyncMock() m.attach = AsyncMock()
m.send_text = AsyncMock(return_value=True)
m.send_enter = AsyncMock(return_value=True)
m.window_exists = AsyncMock(return_value=True) m.window_exists = AsyncMock(return_value=True)
m.respawn_verified = AsyncMock( m.respawn_verified = AsyncMock(
return_value=SpawnResult(ok=True, window_id=None, error="") return_value=SpawnResult(ok=True, window_id=None, error="")
@@ -93,6 +97,367 @@ async def test_list_sessions(service, db, tmux):
assert result[0].alive is True assert result[0].alive is True
def test_schedule_prompt_creates_pending_row(service, db):
sess = _seed_session(db)
due_at = datetime(2026, 6, 11, 12)
prompt = service.schedule_prompt(sess.id, " run the tests ", due_at)
assert prompt.session_id == sess.id
assert prompt.prompt == "run the tests"
assert prompt.due_at == due_at
assert prompt.status == "pending"
assert prompt.error is None
rows = db.query(ScheduledPrompt).all()
assert len(rows) == 1
def test_schedule_prompt_replaces_existing_row_for_session(service, db):
sess = _seed_session(db)
first_due = datetime(2026, 6, 11, 12)
second_due = datetime(2026, 6, 11, 13)
first = service.schedule_prompt(sess.id, "old prompt", first_due)
second = service.schedule_prompt(sess.id, "new prompt", second_due)
rows = db.query(ScheduledPrompt).all()
assert len(rows) == 1
assert second.id == first.id
assert rows[0].id == first.id
assert rows[0].prompt == "new prompt"
assert rows[0].due_at == second_due
assert rows[0].status == "pending"
assert rows[0].error is None
def test_schedule_prompt_rejects_empty_prompt(service):
due_at = datetime(2026, 6, 11, 12)
with pytest.raises(ServiceError, match="Prompt cannot be empty"):
service.schedule_prompt(1, " ", due_at)
def test_schedule_prompt_rejects_missing_session(service):
due_at = datetime(2026, 6, 11, 12)
with pytest.raises(ServiceError, match="Session not found"):
service.schedule_prompt(999, "hello", due_at)
def test_cancel_scheduled_prompt_deletes_row(service, db):
sess = _seed_session(db)
due_at = datetime(2026, 6, 11, 12)
service.schedule_prompt(sess.id, "run the tests", due_at)
service.cancel_scheduled_prompt(sess.id)
assert db.query(ScheduledPrompt).all() == []
def test_get_scheduled_prompt_returns_pending_only(service, db):
sess = _seed_session(db)
due_at = datetime(2026, 6, 11, 12)
service.schedule_prompt(sess.id, "run the tests", due_at)
prompt = service.get_scheduled_prompt(sess.id)
assert prompt is not None
assert prompt.status == "pending"
row = db.query(ScheduledPrompt).filter_by(session_id=sess.id).one()
row.status = "failed"
db.commit()
assert service.get_scheduled_prompt(sess.id) is None
@pytest.mark.asyncio
async def test_list_sessions_includes_pending_scheduled_prompt(service, db, tmux):
from hqt.tmux.runner import WindowInfo
sess = _seed_session(db)
due_at = datetime(2026, 6, 11, 12)
service.schedule_prompt(sess.id, "run the tests", due_at)
tmux.poll_info.return_value = {
sess.tmux_session_name: WindowInfo(alive=True, last_activity=1000)
}
result = await service.list_sessions(project_id=1)
assert result[0].scheduled_prompt is not None
assert result[0].scheduled_prompt.prompt == "run the tests"
assert result[0].scheduled_prompt.status == "pending"
@pytest.mark.asyncio
async def test_dispatch_due_prompt_alive_window_sends_and_marks_sent(service, db, tmux):
sess = _seed_session(db)
due = datetime.now() - timedelta(seconds=1)
scheduled = service.schedule_prompt(sess.id, "continue", due)
tmux.is_alive = AsyncMock(return_value=True)
results = await service.dispatch_due_prompts(now=datetime.now())
assert len(results) == 1
assert results[0].ok is True
assert results[0].session_id == sess.id
assert results[0].window_name == "hqt-rename"
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
tmux.send_enter.assert_awaited_once_with("hqt-rename")
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "sent"
assert row.sent_at is not None
@pytest.mark.asyncio
async def test_dispatch_due_prompt_dead_window_resumes_without_attach(
service, db, tmux, harnesses
):
sess = _seed_session(db)
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
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)
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=MagicMock(
command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj")
)
)
results = await service.dispatch_due_prompts(now=datetime.now())
assert len(results) == 1
assert results[0].ok is True
tmux.respawn_verified.assert_awaited()
tmux.attach.assert_not_called()
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
tmux.send_enter.assert_awaited_once_with("hqt-rename")
@pytest.mark.asyncio
async def test_dispatch_due_prompt_resume_failure_marks_failed(
service, db, tmux, harnesses
):
sess = _seed_session(db)
scheduled = service.schedule_prompt(
sess.id, "continue", datetime.now() - timedelta(seconds=1)
)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(
return_value=SpawnResult(ok=False, window_id=None, error="resume failed")
)
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=MagicMock(
command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj")
)
)
harnesses["claude-code"].build_spawn_config = MagicMock(
return_value=MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
)
results = await service.dispatch_due_prompts(now=datetime.now())
assert len(results) == 1
assert results[0].ok is False
assert "resume failed" in results[0].error
tmux.send_text.assert_not_called()
tmux.send_enter.assert_not_called()
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "failed"
assert "resume failed" in row.error
@pytest.mark.asyncio
async def test_dispatch_due_prompt_send_failure_does_not_retry_next_poll(
service, db, tmux
):
sess = _seed_session(db)
scheduled = service.schedule_prompt(
sess.id, "continue", datetime.now() - timedelta(seconds=1)
)
tmux.is_alive = AsyncMock(return_value=True)
tmux.send_text = AsyncMock(return_value=False)
first = await service.dispatch_due_prompts(now=datetime.now())
second = await service.dispatch_due_prompts(now=datetime.now())
assert len(first) == 1
assert first[0].ok is False
assert second == []
assert tmux.send_text.await_count == 1
tmux.send_enter.assert_not_called()
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "failed"
assert "send text failed" in row.error
@pytest.mark.asyncio
async def test_dispatch_due_prompt_missing_worktree_recreates_without_attach(
factory, db, tmux, harnesses, fake_worktree
):
missing = "/tmp/does-not-exist/.worktrees/feature-x"
sess = _seed_worktree_session(db, path=missing)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=MagicMock(command=["claude"], env={}, cwd=Path(missing))
)
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)
results = await service.dispatch_due_prompts(now=datetime.now())
assert results[0].ok is True
assert fake_worktree.add_calls == [(Path("/tmp/myproj"), "feature-x")]
tmux.attach.assert_not_called()
tmux.send_text.assert_awaited_once_with("hqt-wt", "continue")
@pytest.mark.asyncio
async def test_dispatch_due_prompt_late_capture_updates_id_before_resume(
factory, db, tmux, harnesses_capture_fallback
):
harness = harnesses_capture_fallback["claude-code"]
harness.capture_session_id.return_value = "late-codex-id"
sess = _seed_session(db)
sess.harness_session_id = "placeholder-id"
db.commit()
service = SessionService(
factory=factory, tmux=tmux, harnesses=harnesses_capture_fallback
)
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(
return_value=SpawnResult(ok=True, window_id="@2", error="")
)
results = await service.dispatch_due_prompts(now=datetime.now())
assert results[0].ok is True
harness.build_resume_config.assert_called_with(
Path("/tmp/myproj"), "late-codex-id", sess.model, sandboxed=False
)
db.expire_all()
assert db.get(Session, sess.id).harness_session_id == "late-codex-id"
@pytest.mark.asyncio
async def test_dispatch_due_prompt_send_exception_marks_failed_and_does_not_retry(
service, db, tmux
):
sess = _seed_session(db)
scheduled = service.schedule_prompt(
sess.id, "continue", datetime.now() - timedelta(seconds=1)
)
tmux.is_alive = AsyncMock(return_value=True)
tmux.send_text = AsyncMock(side_effect=RuntimeError("send exploded"))
first = await service.dispatch_due_prompts(now=datetime.now())
second = await service.dispatch_due_prompts(now=datetime.now())
assert len(first) == 1
assert first[0].ok is False
assert "send exploded" in first[0].error
assert second == []
tmux.send_text.assert_awaited_once()
tmux.send_enter.assert_not_called()
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "failed"
assert "send exploded" in row.error
@pytest.mark.asyncio
async def test_dispatch_due_prompt_resume_exception_marks_failed_without_send(
service, db, tmux, harnesses
):
sess = _seed_session(db)
scheduled = service.schedule_prompt(
sess.id, "continue", datetime.now() - timedelta(seconds=1)
)
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=MagicMock(
command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj")
)
)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(side_effect=RuntimeError("resume exploded"))
results = await service.dispatch_due_prompts(now=datetime.now())
assert len(results) == 1
assert results[0].ok is False
assert "resume exploded" in results[0].error
tmux.send_text.assert_not_called()
tmux.send_enter.assert_not_called()
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "failed"
assert "resume exploded" in row.error
@pytest.mark.asyncio
async def test_dispatch_due_prompt_send_enter_failure_marks_failed(service, db, tmux):
sess = _seed_session(db)
scheduled = service.schedule_prompt(
sess.id, "continue", datetime.now() - timedelta(seconds=1)
)
tmux.is_alive = AsyncMock(return_value=True)
tmux.send_enter = AsyncMock(return_value=False)
results = await service.dispatch_due_prompts(now=datetime.now())
assert len(results) == 1
assert results[0].ok is False
assert "send enter failed" in results[0].error
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
tmux.send_enter.assert_awaited_once_with("hqt-rename")
db.expire_all()
row = db.get(ScheduledPrompt, scheduled.id)
assert row.status == "failed"
assert "send enter failed" in row.error
@pytest.mark.asyncio
async def test_dispatch_due_prompt_resume_fails_then_fallback_spawn_succeeds(
service, db, tmux, harnesses
):
sess = _seed_session(db)
service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1))
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=MagicMock(
command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj")
)
)
harnesses["claude-code"].build_spawn_config = MagicMock(
return_value=MagicMock(
command=["claude", "--session-id", "sess-seeded"],
env={},
cwd=Path("/tmp/myproj"),
)
)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(
side_effect=[
SpawnResult(ok=False, window_id=None, error="resume failed"),
SpawnResult(ok=True, window_id="@2", error=""),
]
)
results = await service.dispatch_due_prompts(now=datetime.now())
assert results[0].ok is True
assert tmux.respawn_verified.await_count == 2
tmux.send_text.assert_awaited_once_with("hqt-rename", "continue")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_session(service, tmux): async def test_stop_session(service, tmux):
await service.create_session(project_id=1, harness_name="claude-code") await service.create_session(project_id=1, harness_name="claude-code")
@@ -610,6 +975,27 @@ async def test_sync_window_labels_covers_all_projects(factory, db, tmux, harness
assert labelled == {"hqt-1", "hqt-2"} assert labelled == {"hqt-1", "hqt-2"}
@pytest.mark.asyncio
async def test_sync_window_labels_includes_pending_scheduled_prompt(
factory, db, tmux, harnesses
):
"""sync_window_labels returns SessionInfo with pending scheduled prompt metadata."""
from hqt.tmux.runner import WindowInfo
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.create_session(project_id=1, harness_name="claude-code")
due_at = datetime(2026, 6, 11, 12)
service.schedule_prompt(result.session.id, "run the tests", due_at)
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].scheduled_prompt is not None
assert infos[0].scheduled_prompt.prompt == "run the tests"
assert infos[0].scheduled_prompt.status == "pending"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Item 3: real-configurator integration-seam test # Item 3: real-configurator integration-seam test
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -816,7 +1202,7 @@ async def test_attach_late_capture_updates_id_before_resume(
assert ok is True assert ok is True
harness.build_resume_config.assert_called_with( harness.build_resume_config.assert_called_with(
Path("/tmp/myproj"), "late-codex-id", result.session.model Path("/tmp/myproj"), "late-codex-id", result.session.model, sandboxed=False
) )
from hqt.db.models import Session as DBSession from hqt.db.models import Session as DBSession
@@ -985,16 +1371,15 @@ async def test_create_session_result_propagates_spawn_failure(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _seed_session(db, nickname=None): def _seed_session(db, project_id: int = 1, nickname=None) -> Session:
"""Create a session for project 1 / harness 'claude-code' (seeded by the db fixture).""" """Create a session for the seeded 'claude-code' harness."""
from hqt.db.models import Harness, Session
harness = db.query(Harness).filter_by(name="claude-code").first() harness = db.query(Harness).filter_by(name="claude-code").first()
sess = Session( sess = Session(
project_id=1, project_id=project_id,
harness_id=harness.id, harness_id=harness.id,
nickname=nickname, nickname=nickname,
tmux_session_name="hqt-rename", tmux_session_name="hqt-rename",
harness_session_id="sess-seeded",
archived=False, archived=False,
) )
db.add(sess) db.add(sess)
@@ -1406,6 +1791,20 @@ async def test_delete_session_remove_worktree_false_skips_removal(
assert db.get(Session, sess_id) is None assert db.get(Session, sess_id) is None
@pytest.mark.asyncio
async def test_delete_session_deletes_scheduled_prompt(service, db):
sess = _seed_session(db)
sess_id = sess.id
due_at = datetime(2026, 6, 11, 12)
service.schedule_prompt(sess_id, "run the tests", due_at)
await service.delete_session(sess_id)
db.expire_all()
assert db.get(Session, sess_id) is None
assert db.query(ScheduledPrompt).filter_by(session_id=sess_id).one_or_none() is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_worktree_state_for_plain_session_returns_none( async def test_worktree_state_for_plain_session_returns_none(
service, db, tmux, harnesses, fake_worktree service, db, tmux, harnesses, fake_worktree
@@ -1520,6 +1919,99 @@ async def test_create_session_no_lock_for_non_capturing_harness(service, db, tmu
assert observed["locked_during_spawn"] is False assert observed["locked_during_spawn"] is False
# ---------------------------------------------------------------------------
# Task 5: sandbox wrapping on spawn and resume
# ---------------------------------------------------------------------------
@pytest.fixture
def sandbox_harness():
h = MagicMock()
h.captures_session_id = False
h.generate_session_id.return_value = "sess-1"
h.build_spawn_config.return_value = MagicMock(
command=["claude", "--dangerously-skip-permissions"],
env={},
cwd=Path("/tmp/myproj"),
)
h.sandbox_binds.return_value = []
h.parse_status = MagicMock(return_value=None)
return {"claude-code": h}
@pytest.fixture
def sandbox_service(factory, db, tmux, sandbox_harness):
return SessionService(factory=factory, tmux=tmux, harnesses=sandbox_harness)
@pytest.mark.asyncio
async def test_sandboxed_create_wraps_command(
sandbox_service, db, tmux, sandbox_harness
):
with (
patch("hqt.sessions.service.sandbox.is_available", return_value=True),
patch(
"hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"]
) as mock_wrap,
):
result = await sandbox_service.create_session(
project_id=1,
harness_name="claude-code",
sandbox=SandboxPolicy(fs="rw", net=True),
)
mock_wrap.assert_called_once()
sandbox_harness["claude-code"].build_spawn_config.assert_called_once()
assert (
sandbox_harness["claude-code"].build_spawn_config.call_args.kwargs["sandboxed"]
is True
)
assert tmux.spawn.call_args.args[0].command == ["bwrap", "--", "claude"]
assert result.session.sandbox_json == '{"fs": "rw", "net": true}'
@pytest.mark.asyncio
async def test_unsandboxed_create_does_not_wrap(service, db, tmux, harnesses):
with patch("hqt.sessions.service.sandbox.wrap") as mock_wrap:
result = await service.create_session(project_id=1, harness_name="claude-code")
mock_wrap.assert_not_called()
assert result.session.sandbox_json is None
@pytest.mark.asyncio
async def test_sandboxed_create_raises_when_bwrap_missing(sandbox_service, db):
with patch("hqt.sessions.service.sandbox.is_available", return_value=False):
with pytest.raises(ServiceError, match="bubblewrap"):
await sandbox_service.create_session(
project_id=1,
harness_name="claude-code",
sandbox=SandboxPolicy(fs="rw", net=True),
)
@pytest.mark.asyncio
async def test_sandboxed_worktree_binds_repo_git_dir(
sandbox_service, db, tmux, sandbox_harness, fake_worktree
):
"""A sandboxed worktree session binds the repo's common .git writable so
git works inside the jail (the worktree's gitdir lives under <repo>/.git)."""
from hqt.sandbox import Bind
with (
patch("hqt.sessions.service.sandbox.is_available", return_value=True),
patch(
"hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"]
) as mock_wrap,
):
await sandbox_service.create_session(
project_id=1,
harness_name="claude-code",
worktree_branch="feature-x",
sandbox=SandboxPolicy(fs="rw", net=True),
)
binds = mock_wrap.call_args.args[3]
assert Bind(Path("/tmp/myproj/.git"), writable=True) in binds
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Task 4: SessionService tool-window methods # Task 4: SessionService tool-window methods
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1581,6 +2073,15 @@ async def test_session_id_for_window_resolves_and_misses(service, db):
assert service.session_id_for_window("not-an-hqt-window") is None assert service.session_id_for_window("not-an-hqt-window") is None
@pytest.mark.asyncio
async def test_require_session_id_for_window_resolves_or_raises(service, db):
# The shared guard behind tool/stop/rename: id on a hit, ServiceError on a miss.
await service.create_session(project_id=1, harness_name="claude-code")
assert service.require_session_id_for_window("hqt-1") == 1
with pytest.raises(ServiceError):
service.require_session_id_for_window("not-an-hqt-window")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_tool_window_for_window_resolves_by_name( async def test_open_tool_window_for_window_resolves_by_name(
service, db, tmux, monkeypatch service, db, tmux, monkeypatch
@@ -1636,3 +2137,76 @@ async def test_clone_session_for_window_reuses_project_harness_model(
async def test_clone_session_for_window_unknown_window_raises(service): async def test_clone_session_for_window_unknown_window_raises(service):
with pytest.raises(ServiceError): with pytest.raises(ServiceError):
await service.clone_session_for_window("not-an-hqt-window") await service.clone_session_for_window("not-an-hqt-window")
@pytest.mark.asyncio
async def test_stop_session_for_window_kills_by_name(service, db, tmux):
await service.create_session(project_id=1, harness_name="claude-code")
await service.stop_session_for_window("hqt-1")
tmux.kill.assert_called_once_with("hqt-1")
@pytest.mark.asyncio
async def test_stop_session_for_window_unknown_window_raises(service):
with pytest.raises(ServiceError):
await service.stop_session_for_window("not-an-hqt-window")
@pytest.mark.asyncio
async def test_list_sessions_archived_filter(service, db, tmux):
h = db.query(Harness).filter_by(name="claude-code").first()
active = Session(
project_id=1, harness_id=h.id, tmux_session_name="hqt-active", archived=False
)
arch = Session(
project_id=1, harness_id=h.id, tmux_session_name="hqt-arch", archived=True
)
db.add_all([active, arch])
db.commit()
active_id, arch_id = active.id, arch.id
default = await service.list_sessions(project_id=1)
assert [i.session.id for i in default] == [active_id]
archived = await service.list_sessions(project_id=1, archived=True)
assert [i.session.id for i in archived] == [arch_id]
@pytest.mark.asyncio
async def test_archive_session_kills_window_and_keeps_row(service, db, tmux):
await service.create_session(project_id=1, harness_name="claude-code")
await service.archive_session(1)
tmux.kill.assert_called_once_with("hqt-1")
db.expire_all()
row = db.get(Session, 1)
assert row is not None
assert row.archived is True
# Excluded from the default (active) listing.
assert await service.list_sessions(project_id=1) == []
@pytest.mark.asyncio
async def test_archive_session_no_kill_when_window_missing(service, db, tmux):
await service.create_session(project_id=1, harness_name="claude-code")
tmux.window_exists = AsyncMock(return_value=False)
tmux.kill.reset_mock()
await service.archive_session(1)
tmux.kill.assert_not_called()
db.expire_all()
assert db.get(Session, 1).archived is True
@pytest.mark.asyncio
async def test_unarchive_session_restores_to_active_list(service, db, tmux):
await service.create_session(project_id=1, harness_name="claude-code")
await service.archive_session(1)
await service.unarchive_session(1)
db.expire_all()
assert db.get(Session, 1).archived is False
active = await service.list_sessions(project_id=1)
assert [i.session.id for i in active] == [1]
+233 -6
View File
@@ -139,6 +139,154 @@ async def test_list_windows(runner):
assert result == ["hqt", "hqt-1", "hqt-2"] assert result == ["hqt", "hqt-1", "hqt-2"]
@pytest.mark.asyncio
async def test_reset_window_indexes_missing_home_returns_false(runner):
runner._exec.return_value = (
0,
"@1\t1\thqt-foo\n@2\t4\thqt-bar\n",
"",
)
result = await runner.reset_window_indexes("⌂ HQT")
assert result is False
runner._exec.assert_called_once_with(
"list-windows",
"-t",
"hqt-main",
"-F",
"#{window_id}\t#{window_index}\t#{window_name}",
)
@pytest.mark.asyncio
async def test_reset_window_indexes_already_compact_no_moves(runner):
runner._exec.return_value = (
0,
"@0\t0\t⌂ HQT\n@1\t1\thqt-foo\n@2\t2\thqt-bar\n",
"",
)
result = await runner.reset_window_indexes("⌂ HQT")
assert result is True
assert runner._exec.call_count == 1
@pytest.mark.asyncio
async def test_reset_window_indexes_sparse_indexes_compact_in_current_order(runner):
runner._exec.side_effect = [
(
0,
"@0\t0\t⌂ HQT\n@1\t1\thqt-foo\n@4\t4\thqt-bar\n@9\t9\thqt-baz\n",
"",
),
(0, "", ""),
(0, "", ""),
(0, "", ""),
(0, "", ""),
(0, "", ""),
(0, "", ""),
(0, "", ""),
(0, "", ""),
]
result = await runner.reset_window_indexes("⌂ HQT")
assert result is True
assert runner._exec.call_count == 9
calls = [call.args for call in runner._exec.call_args_list]
assert calls[1] == ("move-window", "-d", "-s", "@0", "-t", "hqt-main:14")
assert calls[2] == ("move-window", "-d", "-s", "@1", "-t", "hqt-main:15")
assert calls[3] == ("move-window", "-d", "-s", "@4", "-t", "hqt-main:16")
assert calls[4] == ("move-window", "-d", "-s", "@9", "-t", "hqt-main:17")
assert calls[5] == ("move-window", "-d", "-s", "@0", "-t", "hqt-main:0")
assert calls[6] == ("move-window", "-d", "-s", "@1", "-t", "hqt-main:1")
assert calls[7] == ("move-window", "-d", "-s", "@4", "-t", "hqt-main:2")
assert calls[8] == ("move-window", "-d", "-s", "@9", "-t", "hqt-main:3")
@pytest.mark.asyncio
async def test_reset_window_indexes_home_not_zero_moves_home_first_in_final_layout(
runner,
):
runner._exec.side_effect = [
(
0,
"@2\t2\thqt-foo\n@5\t5\t⌂ HQT\n@7\t7\thqt-bar\n",
"",
),
(0, "", ""),
(0, "", ""),
(0, "", ""),
(0, "", ""),
(0, "", ""),
(0, "", ""),
]
result = await runner.reset_window_indexes("⌂ HQT")
assert result is True
calls = [call.args for call in runner._exec.call_args_list]
assert calls[1] == ("move-window", "-d", "-s", "@2", "-t", "hqt-main:11")
assert calls[2] == ("move-window", "-d", "-s", "@5", "-t", "hqt-main:12")
assert calls[3] == ("move-window", "-d", "-s", "@7", "-t", "hqt-main:13")
assert calls[4] == ("move-window", "-d", "-s", "@5", "-t", "hqt-main:0")
assert calls[5] == ("move-window", "-d", "-s", "@2", "-t", "hqt-main:1")
assert calls[6] == ("move-window", "-d", "-s", "@7", "-t", "hqt-main:2")
@pytest.mark.asyncio
async def test_reset_window_indexes_move_failure_returns_false(runner):
runner._exec.side_effect = [
(
0,
"@0\t0\t⌂ HQT\n@1\t1\thqt-foo\n@4\t4\thqt-bar\n",
"",
),
(0, "", ""),
(1, "", "index in use"),
]
result = await runner.reset_window_indexes("⌂ HQT")
assert result is False
assert runner._exec.call_count == 3
calls = [call.args for call in runner._exec.call_args_list]
assert calls[1] == ("move-window", "-d", "-s", "@0", "-t", "hqt-main:8")
assert calls[2] == ("move-window", "-d", "-s", "@1", "-t", "hqt-main:9")
@pytest.mark.asyncio
async def test_reset_window_indexes_list_failure_returns_false(runner):
runner._exec.return_value = (1, "", "no server running")
result = await runner.reset_window_indexes("⌂ HQT")
assert result is False
runner._exec.assert_called_once_with(
"list-windows",
"-t",
"hqt-main",
"-F",
"#{window_id}\t#{window_index}\t#{window_name}",
)
@pytest.mark.asyncio
async def test_reset_window_indexes_malformed_list_line_returns_false(runner):
runner._exec.return_value = (
0,
"@0\t0\t⌂ HQT\nnot-enough-columns\n",
"",
)
result = await runner.reset_window_indexes("⌂ HQT")
assert result is False
assert runner._exec.call_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_manager_spawn(runner): async def test_manager_spawn(runner):
runner._exec.side_effect = [ runner._exec.side_effect = [
@@ -549,6 +697,46 @@ async def test_respawn_pane_no_env(runner):
assert "-e" not in call_args assert "-e" not in call_args
@pytest.mark.asyncio
async def test_send_text_returns_true_on_success(runner):
runner._exec.return_value = (0, "", "")
assert await runner.send_text("hqt-1", "hello") is True
@pytest.mark.asyncio
async def test_send_text_returns_false_on_failure(runner):
runner._exec.return_value = (1, "", "no such target")
assert await runner.send_text("hqt-1", "hello") is False
@pytest.mark.asyncio
async def test_send_enter_returns_true_on_success(runner):
runner._exec.return_value = (0, "", "")
assert await runner.send_enter("hqt-1") is True
@pytest.mark.asyncio
async def test_send_enter_returns_false_on_failure(runner):
runner._exec.return_value = (1, "", "no such target")
assert await runner.send_enter("hqt-1") is False
@pytest.mark.asyncio
async def test_manager_send_delegates(runner):
mgr = TmuxManager(runner)
runner.send_text = AsyncMock(return_value=True)
runner.send_enter = AsyncMock(return_value=True)
assert await mgr.send_text("hqt-1", "continue") is True
assert await mgr.send_enter("hqt-1") is True
runner.send_text.assert_awaited_once_with("hqt-1", "continue")
runner.send_enter.assert_awaited_once_with("hqt-1")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_spawn_verify_dead_empty_text_returns_fallback_error(runner): async def test_spawn_verify_dead_empty_text_returns_fallback_error(runner):
"""When verify_window_alive returns (False, ''), spawn returns the fallback error message.""" """When verify_window_alive returns (False, ''), spawn returns the fallback error message."""
@@ -896,6 +1084,17 @@ async def test_manager_set_window_label_delegates(runner):
assert "○ deadproj" in args assert "○ deadproj" in args
@pytest.mark.asyncio
async def test_manager_reset_window_indexes_delegates(runner):
mgr = TmuxManager(runner)
runner.reset_window_indexes = AsyncMock(return_value=True)
result = await mgr.reset_window_indexes("⌂ HQT")
assert result is True
runner.reset_window_indexes.assert_awaited_once_with("⌂ HQT")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_apply_theme_sets_session_scoped_status_options(runner): async def test_apply_theme_sets_session_scoped_status_options(runner):
"""apply_theme themes the status bar in one atomic styling call. """apply_theme themes the status bar in one atomic styling call.
@@ -1021,7 +1220,11 @@ async def test_new_aux_window_spawns_styles_and_selects(runner):
runner._exec.side_effect = [ runner._exec.side_effect = [
(0, "0\n", ""), # _next_window_index: indices [0] -> next index 1 (0, "0\n", ""), # _next_window_index: indices [0] -> next index 1
(0, "@7\n", ""), # new-window -P -F '#{window_id}' (0, "@7\n", ""), # new-window -P -F '#{window_id}'
(0, "", ""), # set-option (automatic-rename + @hqt_label + theme) (
0,
"",
"",
), # set-option (automatic-rename + allow-rename + @hqt_label + theme)
(0, "", ""), # select-window (0, "", ""), # select-window
] ]
wid = await runner.new_aux_window("lazygit", "/proj", ["lazygit"], "lazygit · proj") wid = await runner.new_aux_window("lazygit", "/proj", ["lazygit"], "lazygit · proj")
@@ -1030,13 +1233,29 @@ async def test_new_aux_window_spawns_styles_and_selects(runner):
calls = runner._exec.call_args_list calls = runner._exec.call_args_list
# new-window: next free index, name, cwd, print window_id, then the command. # new-window: next free index, name, cwd, print window_id, then the command.
assert calls[1].args == ( assert calls[1].args == (
"new-window", "-t", "hqt-main:1", "-n", "lazygit", "new-window",
"-c", "/proj", "-P", "-F", "#{window_id}", "lazygit", "-t",
"hqt-main:1",
"-n",
"lazygit",
"-c",
"/proj",
"-P",
"-F",
"#{window_id}",
"lazygit",
) )
# post-creation options target the window_id and NEVER set remain-on-exit. # post-creation options target the window_id and NEVER set remain-on-exit.
assert calls[2].args[:6] == ( assert calls[2].args[:6] == (
"set-option", "-w", "-t", "@7", "automatic-rename", "off", "set-option",
"-w",
"-t",
"@7",
"automatic-rename",
"off",
) )
# allow-rename off too, so nvim/lazygit OSC titles can't scramble the label.
assert "allow-rename" in calls[2].args
assert "@hqt_label" in calls[2].args assert "@hqt_label" in calls[2].args
assert "lazygit · proj" in calls[2].args assert "lazygit · proj" in calls[2].args
assert "remain-on-exit" not in calls[2].args assert "remain-on-exit" not in calls[2].args
@@ -1056,8 +1275,16 @@ async def test_new_aux_window_shell_omits_command_arg(runner):
assert wid == "@9" assert wid == "@9"
calls = runner._exec.call_args_list calls = runner._exec.call_args_list
assert calls[1].args == ( assert calls[1].args == (
"new-window", "-t", "hqt-main:3", "-n", "shell", "new-window",
"-c", "/proj", "-P", "-F", "#{window_id}", "-t",
"hqt-main:3",
"-n",
"shell",
"-c",
"/proj",
"-P",
"-F",
"#{window_id}",
) )
+700 -9
View File
@@ -292,7 +292,12 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
call_order: list[str] = [] call_order: list[str] = []
async def fake_create( async def fake_create(
project_id, harness_name, nickname, model, worktree_branch=None project_id,
harness_name,
nickname,
model,
worktree_branch=None,
sandbox=None,
): ):
# Brief yield so that, if two separate workers were used, the list # Brief yield so that, if two separate workers were used, the list
# worker would be able to start and record "list" before this # worker would be able to start and record "list" before this
@@ -305,7 +310,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
session=MagicMock(), spawn_ok=True, spawn_error="" session=MagicMock(), spawn_ok=True, spawn_error=""
) )
async def fake_list(project_id): async def fake_list(project_id, archived=False):
call_order.append("list") call_order.append("list")
return [] return []
@@ -324,7 +329,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
await pilot.pause() await pilot.pause()
# The NewSessionScreen is now on top; dismiss it with a valid result tuple. # The NewSessionScreen is now on top; dismiss it with a valid result tuple.
app.screen.dismiss(("claude", "mynick", None, None)) app.screen.dismiss(("claude", "mynick", None, None, None))
await pilot.pause() await pilot.pause()
# Wait for the worker spawned by on_dismiss to finish deterministically. # Wait for the worker spawned by on_dismiss to finish deterministically.
@@ -396,7 +401,7 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure():
await app.workers.wait_for_complete() await app.workers.wait_for_complete()
await pilot.pause() await pilot.pause()
app.screen.dismiss(("claude", "mynick", None, None)) app.screen.dismiss(("claude", "mynick", None, None, None))
await pilot.pause() await pilot.pause()
await app.workers.wait_for_complete() await app.workers.wait_for_complete()
await pilot.pause() await pilot.pause()
@@ -467,7 +472,7 @@ async def test_new_session_on_dismiss_no_notify_on_success():
await app.workers.wait_for_complete() await app.workers.wait_for_complete()
await pilot.pause() await pilot.pause()
app.screen.dismiss(("claude", "mynick", None, None)) app.screen.dismiss(("claude", "mynick", None, None, None))
await pilot.pause() await pilot.pause()
await app.workers.wait_for_complete() await app.workers.wait_for_complete()
await pilot.pause() await pilot.pause()
@@ -582,7 +587,7 @@ async def test_new_session_enter_in_input_submits_dialog():
await pilot.press("enter") await pilot.press("enter")
await pilot.pause() await pilot.pause()
assert results == [("claude", "mywork", None, None)], f"got {results}" assert results == [("claude", "mywork", None, None, None)], f"got {results}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1027,6 +1032,51 @@ def test_format_session_text_status_colors():
) # Overlay0 ) # Overlay0
def _make_scheduled_prompt(due_at):
prompt = MagicMock()
prompt.due_at = due_at
prompt.status = "pending"
return prompt
def test_format_session_text_shows_scheduled_prompt_countdown():
from datetime import datetime, timedelta
from hqt.tui.widgets.session_list import format_session_text
now = datetime(2026, 6, 11, 14, 0, 0)
scheduled = _make_scheduled_prompt(now + timedelta(minutes=12, seconds=1))
text = format_session_text(
"mywork",
"claude",
"idle",
"",
scheduled_prompt=scheduled,
now=now,
)
assert "@ 13m" in text
def test_format_session_text_shows_scheduled_prompt_clock_time():
from datetime import datetime, timedelta
from hqt.tui.widgets.session_list import format_session_text
now = datetime(2026, 6, 11, 14, 0, 0)
scheduled = _make_scheduled_prompt(now + timedelta(hours=2, minutes=30))
text = format_session_text(
"mywork",
"claude",
"idle",
"",
scheduled_prompt=scheduled,
now=now,
)
assert "@ 16:30" in text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_session_list_renders_harness_name_brackets(): async def test_session_list_renders_harness_name_brackets():
"""Regression: '[claude]' must be visible, not swallowed as a markup tag.""" """Regression: '[claude]' must be visible, not swallowed as a markup tag."""
@@ -1045,6 +1095,121 @@ async def test_session_list_renders_harness_name_brackets():
assert any("[claude]" in t for t in texts), f"harness name missing: {texts}" assert any("[claude]" in t for t in texts), f"harness name missing: {texts}"
# ---------------------------------------------------------------------------
# SchedulePromptScreen: parse + submit
# ---------------------------------------------------------------------------
def test_parse_delay_accepts_compact_seconds_minutes_hours():
from datetime import timedelta
from hqt.tui.screens.schedule_prompt import parse_delay
assert parse_delay("90s") == timedelta(seconds=90)
assert parse_delay("30m") == timedelta(minutes=30)
assert parse_delay("2h") == timedelta(hours=2)
assert parse_delay("1h30m") == timedelta(hours=1, minutes=30)
@pytest.mark.parametrize("value", ["", "0m", "abc", "1d", "1h 30m"])
def test_parse_delay_rejects_invalid_values(value):
from hqt.tui.screens.schedule_prompt import parse_delay
with pytest.raises(ValueError):
parse_delay(value)
@pytest.mark.asyncio
@pytest.mark.parametrize("submit_action", ["enter", "ok"])
async def test_schedule_prompt_screen_submits_prompt_and_delay(submit_action):
from datetime import timedelta
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
from textual.widgets import Button, Input
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
results = []
app.push_screen(SchedulePromptScreen(), results.append)
await pilot.pause()
prompt = app.screen.query_one("#prompt-input", Input)
prompt.value = "continue work"
app.screen.query_one("#delay-input", Input).value = "1h30m"
prompt.focus()
await pilot.pause()
if submit_action == "enter":
await pilot.press("enter")
else:
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("continue work", timedelta(hours=1, minutes=30))]
@pytest.mark.asyncio
async def test_schedule_prompt_screen_invalid_delay_stays_open_with_error():
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
from textual.widgets import Button, Input, Label
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
results = []
app.push_screen(SchedulePromptScreen(), results.append)
await pilot.pause()
app.screen.query_one("#prompt-input", Input).value = "continue work"
app.screen.query_one("#delay-input", Input).value = "1h 30m"
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert isinstance(app.screen, SchedulePromptScreen)
assert results == []
error = app.screen.query_one("#schedule-error", Label)
assert "Use a delay like" in str(error.render())
assert app.screen.focused == app.screen.query_one("#delay-input", Input)
@pytest.mark.asyncio
async def test_schedule_prompt_screen_empty_prompt_stays_open_with_error():
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
from textual.widgets import Button, Input, Label
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
results = []
app.push_screen(SchedulePromptScreen(), results.append)
await pilot.pause()
app.screen.query_one("#prompt-input", Input).value = " "
app.screen.query_one("#delay-input", Input).value = "30m"
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert isinstance(app.screen, SchedulePromptScreen)
assert results == []
error = app.screen.query_one("#schedule-error", Label)
assert "Prompt cannot be empty" in str(error.render())
assert app.screen.focused == app.screen.query_one("#prompt-input", Input)
@pytest.mark.asyncio
async def test_schedule_prompt_screen_cancel_returns_none():
from hqt.tui.screens.schedule_prompt import SchedulePromptScreen
from textual.widgets import Button
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
results = []
app.push_screen(SchedulePromptScreen(), results.append)
await pilot.pause()
app.screen.query_one("#cancel-btn", Button).press()
await pilot.pause()
assert results == [None]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RenameSessionScreen: prefill + submit # RenameSessionScreen: prefill + submit
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1291,7 +1456,7 @@ async def test_new_session_worktree_unchecked_returns_none_branch():
app.screen.query_one("#nickname-input", Input).value = "mywork" app.screen.query_one("#nickname-input", Input).value = "mywork"
app.screen.query_one("#ok-btn", Button).press() app.screen.query_one("#ok-btn", Button).press()
await pilot.pause() await pilot.pause()
assert results == [("claude", "mywork", None, None)], f"got {results}" assert results == [("claude", "mywork", None, None, None)], f"got {results}"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1316,7 +1481,9 @@ async def test_new_session_worktree_checked_uses_typed_branch():
branch.value = "feature-x" branch.value = "feature-x"
app.screen.query_one("#ok-btn", Button).press() app.screen.query_one("#ok-btn", Button).press()
await pilot.pause() await pilot.pause()
assert results == [("claude", "My Work", None, "feature-x")], f"got {results}" assert results == [("claude", "My Work", None, "feature-x", None)], (
f"got {results}"
)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1338,7 +1505,7 @@ async def test_new_session_worktree_checked_blank_branch_slugifies_nickname():
app.screen.query_one("#branch-input", Input).value = "" app.screen.query_one("#branch-input", Input).value = ""
app.screen.query_one("#ok-btn", Button).press() app.screen.query_one("#ok-btn", Button).press()
await pilot.pause() await pilot.pause()
assert results == [("claude", "Cool Feature!", None, "cool-feature")], ( assert results == [("claude", "Cool Feature!", None, "cool-feature", None)], (
f"got {results}" f"got {results}"
) )
@@ -1504,3 +1671,527 @@ async def test_delete_non_worktree_session_does_not_push_modal():
verify_db = app._db_factory() verify_db = app._db_factory()
assert verify_db.get(Session, sess_id) is None assert verify_db.get(Session, sess_id) is None
verify_db.close() verify_db.close()
# ---------------------------------------------------------------------------
# Task 6: Sandbox controls in NewSessionScreen
# ---------------------------------------------------------------------------
def test_policy_from_disabled_returns_none():
from hqt.tui.screens.new_session import NewSessionScreen
assert NewSessionScreen._policy_from(False, "rw", True) is None
def test_policy_from_enabled_builds_policy():
from hqt.sandbox import SandboxPolicy
from hqt.tui.screens.new_session import NewSessionScreen
p = NewSessionScreen._policy_from(True, "ro", False)
assert p == SandboxPolicy(fs="ro", net=False)
@pytest.mark.asyncio
async def test_sandbox_switch_disabled_when_unavailable():
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Switch
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
app.push_screen(
NewSessionScreen(["claude"], is_git_repo=False, sandbox_available=False)
)
await pilot.pause()
assert app.screen.query_one("#sandbox-switch", Switch).disabled is True
@pytest.mark.asyncio
async def test_sandbox_switch_enabled_when_available():
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Switch
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
app.push_screen(
NewSessionScreen(["claude"], is_git_repo=False, sandbox_available=True)
)
await pilot.pause()
assert app.screen.query_one("#sandbox-switch", Switch).disabled is False
# ---------------------------------------------------------------------------
# delayed prompt: bindings, actions, poll dispatch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prompt_later_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert "p" in bindings
assert bindings["p"].action == "schedule_prompt"
assert "P" in bindings
assert bindings["P"].action == "cancel_scheduled_prompt"
@pytest.mark.asyncio
async def test_schedule_prompt_action_calls_service_and_refreshes():
from datetime import datetime
from unittest.mock import patch
from hqt.db.models import Harness, Project, Session
from hqt.tui.widgets.session_list import SessionList
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
seed_db = app._db_factory()
proj = Project(name="prompt-proj", path="/tmp/prompt-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-prompt-test",
archived=False,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
app._selected_project_id = proj_id
await app._refresh_sessions()
await pilot.pause()
app.query_one(SessionList).query_one("#session-list").focus()
await pilot.press("down")
await pilot.pause()
mock_service = MagicMock()
mock_service.schedule_prompt = MagicMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
mock_service.dispatch_due_prompts = AsyncMock(return_value=[])
app._session_service = mock_service
notify_calls = []
def capture_notify(message, **kwargs):
notify_calls.append((message, kwargs))
with patch.object(app, "notify", side_effect=capture_notify):
app.action_schedule_prompt()
await pilot.pause()
app.screen.dismiss(
("continue", __import__("datetime").timedelta(minutes=30))
)
await pilot.pause()
await app.workers.wait_for_complete()
assert mock_service.schedule_prompt.call_count == 1
args = mock_service.schedule_prompt.call_args.args
assert args[0] == sess_id
assert args[1] == "continue"
assert isinstance(args[2], datetime)
mock_service.list_sessions.assert_awaited()
assert any("Prompt scheduled" in call[0] for call in notify_calls)
@pytest.mark.asyncio
async def test_cancel_scheduled_prompt_action_calls_service_and_refreshes():
from hqt.db.models import Harness, Project, Session
from hqt.tui.widgets.session_list import SessionList
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
seed_db = app._db_factory()
proj = Project(name="cancel-proj", path="/tmp/cancel-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-cancel-test",
archived=False,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
app._selected_project_id = proj_id
await app._refresh_sessions()
await pilot.pause()
app.query_one(SessionList).query_one("#session-list").focus()
await pilot.press("down")
await pilot.pause()
mock_service = MagicMock()
mock_service.cancel_scheduled_prompt = MagicMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
mock_service.dispatch_due_prompts = AsyncMock(return_value=[])
app._session_service = mock_service
await app.run_action("cancel_scheduled_prompt")
await pilot.pause()
await app.workers.wait_for_complete()
mock_service.cancel_scheduled_prompt.assert_called_once_with(sess_id)
mock_service.list_sessions.assert_awaited()
@pytest.mark.asyncio
async def test_poll_sessions_dispatches_due_prompts_and_notifies():
app = HqtApp()
async with app.run_test(size=(120, 40)):
result = MagicMock()
result.ok = True
result.window_name = "hqt-1"
result.error = ""
mock_service = MagicMock()
mock_service.dispatch_due_prompts = AsyncMock(return_value=[result])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
notify_calls = []
def capture_notify(message, **kwargs):
notify_calls.append((message, kwargs))
from unittest.mock import patch
with patch.object(app, "notify", side_effect=capture_notify):
await app._poll_sessions()
mock_service.dispatch_due_prompts.assert_awaited_once()
assert any("Prompt sent" in call[0] for call in notify_calls)
@pytest.mark.asyncio
async def test_sandbox_options_hidden_until_switch_on():
"""The fs/net rows are sub-options of the sandbox switch: hidden until it is
turned on, shown when on, hidden again when turned back off."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Switch
app.push_screen(
NewSessionScreen(["claude"], is_git_repo=True, sandbox_available=True)
)
await pilot.pause()
options = app.screen.query_one("#sandbox-options")
assert options.display is False
switch = app.screen.query_one("#sandbox-switch", Switch)
switch.value = True
await pilot.pause()
assert options.display is True
switch.value = False
await pilot.pause()
assert options.display is False
def test_dialog_focus_highlight_uses_accent():
"""Every focusable control in the New Session dialog shares the Peach accent
focus border (consistent with the dialog buttons), not Textual's default
Lavender. Guards against the scoped :focus rule being dropped."""
from pathlib import Path
import hqt.tui
css = (Path(hqt.tui.__file__).parent / "styles.tcss").read_text()
for selector in (
"#new-session-dialog Switch:focus",
"#new-session-dialog Checkbox:focus",
"#new-session-dialog Select:focus",
"#new-session-dialog Input:focus",
):
assert selector in css, f"missing focus rule: {selector}"
assert "border: tall $accent;" in css
def test_new_session_has_jk_focus_bindings():
"""j -> focus_next, k -> focus_previous, declared on the screen."""
from hqt.tui.screens.new_session import NewSessionScreen
actions = {b.key: b.action for b in NewSessionScreen.BINDINGS}
assert actions.get("j") == "focus_next"
assert actions.get("k") == "focus_previous"
@pytest.mark.asyncio
async def test_jk_moves_focus_between_controls():
"""On a non-text control, j moves focus to another control."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Switch
app.push_screen(
NewSessionScreen(["claude"], is_git_repo=True, sandbox_available=True)
)
await pilot.pause()
switch = app.screen.query_one("#sandbox-switch", Switch)
switch.focus()
await pilot.pause()
assert app.focused is switch
await pilot.press("j")
await pilot.pause()
assert app.focused is not switch
@pytest.mark.asyncio
async def test_jk_typed_into_input_is_literal():
"""Inside a text input, j/k type literally and do not move focus."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Input
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True))
await pilot.pause()
nickname = app.screen.query_one("#nickname-input", Input)
nickname.focus()
await pilot.pause()
await pilot.press("j", "k")
await pilot.pause()
assert nickname.value == "jk"
assert app.focused is nickname
def test_policy_is_none_when_sandbox_off_regardless_of_suboptions():
"""When sandboxing is off, the (now hidden) fs/net sub-option values are
discarded the submitted policy is None no matter what they hold."""
from hqt.tui.screens.new_session import NewSessionScreen
assert NewSessionScreen._policy_from(False, "ro", False) is None
assert NewSessionScreen._policy_from(False, "rw", True) is None
def test_policy_reflects_suboptions_when_sandbox_on():
"""When sandboxing is on, the fs/net sub-option values flow into the policy."""
from hqt.tui.screens.new_session import NewSessionScreen
policy = NewSessionScreen._policy_from(True, "ro", False)
assert policy is not None
assert policy.fs == "ro"
assert policy.net is False
# ---------------------------------------------------------------------------
# Task 3: h/l column focus navigation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hl_focus_bindings_exist():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert bindings["h"].action == "focus_projects"
assert bindings["l"].action == "focus_sessions"
@pytest.mark.asyncio
async def test_h_focuses_projects_l_focuses_sessions(tmp_path):
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
d = tmp_path / "hl-proj"
d.mkdir()
app._project_service.create("hl", str(d))
app._load_projects()
await app.workers.wait_for_complete()
await pilot.pause()
await pilot.press("l")
await pilot.pause()
assert app.focused is app.query_one("#session-list")
await pilot.press("h")
await pilot.pause()
assert app.focused is app.query_one("#project-list")
@pytest.mark.asyncio
async def test_session_list_header_reflects_archived_mode():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.session_list import SessionList
from textual.widgets import Label
sl = app.query_one(SessionList)
header = sl.query_one("#session-header", Label)
assert str(header.render()) == "Sessions"
sl.set_mode(archived=True)
await pilot.pause()
assert "archived" in str(header.render()).lower()
sl.set_mode(archived=False)
await pilot.pause()
assert str(header.render()) == "Sessions"
@pytest.mark.asyncio
async def test_archive_session_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert bindings["x"].action == "archive_session"
@pytest.mark.asyncio
async def test_archive_session_action_calls_service_and_refreshes():
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
seed_db = app._db_factory()
proj = Project(name="arch-proj", path="/tmp/arch-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-arch-action",
archived=False,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
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
mock_service = MagicMock()
mock_service.archive_session = AsyncMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
await app.run_action("archive_session")
await pilot.pause()
mock_service.archive_session.assert_awaited_once_with(sess_id)
mock_service.list_sessions.assert_awaited()
@pytest.mark.asyncio
async def test_toggle_archived_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert bindings["A"].action == "toggle_archived"
@pytest.mark.asyncio
async def test_toggle_archived_switches_list_and_header():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.session_list import SessionList
from textual.widgets import Label
proj_id = 42
app._selected_project_id = proj_id
mock_service = MagicMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
assert app._show_archived is False
await app.run_action("toggle_archived")
await pilot.pause()
assert app._show_archived is True
mock_service.list_sessions.assert_awaited_with(proj_id, archived=True)
header = app.query_one(SessionList).query_one("#session-header", Label)
assert "archived" in str(header.render()).lower()
await app.run_action("toggle_archived")
await pilot.pause()
assert app._show_archived is False
mock_service.list_sessions.assert_awaited_with(proj_id, archived=False)
assert str(header.render()) == "Sessions"
@pytest.mark.asyncio
async def test_unarchive_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert bindings["u"].action == "unarchive_session"
@pytest.mark.asyncio
async def test_unarchive_action_calls_service_and_refreshes():
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
seed_db = app._db_factory()
proj = Project(name="restore-proj", path="/tmp/restore-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-restore",
archived=True,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
app._selected_project_id = proj_id
app._show_archived = True
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
mock_service = MagicMock()
mock_service.unarchive_session = AsyncMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
await app.run_action("unarchive_session")
await pilot.pause()
mock_service.unarchive_session.assert_awaited_once_with(sess_id)
mock_service.list_sessions.assert_awaited()
@pytest.mark.asyncio
async def test_archive_restore_actions_gated_by_view():
app = HqtApp()
async with app.run_test(size=(120, 40)):
# Active view (default): archive offered, restore hidden.
assert app.check_action("archive_session", ()) is True
assert app.check_action("unarchive_session", ()) is None
# Archived view: restore offered, archive hidden.
app._show_archived = True
assert app.check_action("archive_session", ()) is None
assert app.check_action("unarchive_session", ()) is True