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>
This commit is contained in:
@@ -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.3–0.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.
|
||||
@@ -1,198 +0,0 @@
|
||||
# Delayed prompt injection
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Add a TUI-only feature that schedules one delayed prompt for a specific hqt
|
||||
session and injects it when the selected time arrives. The primary use case is
|
||||
recovering a harness that stopped because it ran out of budget: the user can
|
||||
schedule a restart/continue prompt and let hqt deliver it later.
|
||||
|
||||
Scheduled prompts fire only while the hqt TUI is running. If hqt is closed at
|
||||
the due time, the prompt remains pending and fires on the next TUI poll after
|
||||
hqt starts again.
|
||||
|
||||
## User Behavior
|
||||
|
||||
The session list gets a new keybinding, `p`, labeled "Prompt Later". Pressing
|
||||
it for the selected session opens a modal that collects:
|
||||
|
||||
- Prompt text.
|
||||
- A relative delay such as `30m`, `2h`, `1h30m`, or `90s`.
|
||||
- A confirmation action.
|
||||
|
||||
Each session supports exactly one active delayed prompt. Scheduling a new prompt
|
||||
for that session replaces the existing pending, sent, or failed prompt. A
|
||||
companion cancel action, `P` / `Shift+P`, removes the selected session's active
|
||||
prompt.
|
||||
|
||||
Visualization is intentionally compact:
|
||||
|
||||
- The session row shows the next pending trigger, for example
|
||||
`◉ waiting @ 14:30` or `● idle @ 12m`.
|
||||
- After scheduling, canceling, sending, or failing a prompt, the TUI shows a
|
||||
short notification such as `Prompt scheduled for hqt-4 at 14:30`.
|
||||
|
||||
The row indicator is shown only for pending prompts. Sent prompts disappear from
|
||||
the active indicator. Failed prompts are not retried automatically; they can be
|
||||
replaced or canceled from the selected session.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use a persisted database table and drive dispatch from the existing TUI poller.
|
||||
This matches the current service architecture and keeps scheduling state visible
|
||||
across TUI restarts without introducing a background daemon.
|
||||
|
||||
### Data Model
|
||||
|
||||
Add a `ScheduledPrompt` model and migration:
|
||||
|
||||
- `id` primary key.
|
||||
- `session_id` foreign key to `sessions.id`, unique.
|
||||
- `prompt` text, required.
|
||||
- `due_at` datetime, required.
|
||||
- `status` string, one of `pending`, `sending`, `sent`, or `failed`.
|
||||
- `error` text, nullable.
|
||||
- `created_at` datetime.
|
||||
- `sent_at` datetime, nullable.
|
||||
|
||||
The unique `session_id` constraint enforces one active prompt record per
|
||||
session. Scheduling a prompt replaces any existing prompt row for that session,
|
||||
including failed or sent rows.
|
||||
|
||||
### Service Layer
|
||||
|
||||
`SessionService` owns delayed prompt behavior because it already owns session
|
||||
lookup, resume/fallback behavior, status polling, and tmux interaction.
|
||||
|
||||
Add service methods along these lines:
|
||||
|
||||
```python
|
||||
def schedule_prompt(self, session_id: int, prompt: str, due_at: datetime) -> ScheduledPrompt:
|
||||
...
|
||||
|
||||
def cancel_scheduled_prompt(self, session_id: int) -> None:
|
||||
...
|
||||
|
||||
def get_scheduled_prompt(self, session_id: int) -> ScheduledPrompt | None:
|
||||
...
|
||||
|
||||
async def dispatch_due_prompts(self, now: datetime) -> list[DispatchResult]:
|
||||
...
|
||||
```
|
||||
|
||||
Extend `SessionInfo` with an optional `scheduled_prompt` field so `SessionList`
|
||||
can render the compact row indicator from the same object it already receives
|
||||
for each row.
|
||||
|
||||
### Tmux Layer
|
||||
|
||||
`TmuxRunner` already has literal `send_text()` and `send_enter()` methods. Add
|
||||
thin `TmuxManager` delegates and make both send paths return success/failure
|
||||
instead of fire-and-forget so the service can record failed injection.
|
||||
|
||||
Prompt injection sends the prompt body as literal text, then sends Enter as a
|
||||
separate tmux key event. Prompt content such as `-flag`, quotes, semicolons, or
|
||||
the word `Enter` must be treated as text, not as tmux commands or key names.
|
||||
|
||||
### TUI Runtime
|
||||
|
||||
The existing 3-second TUI poll loop becomes the scheduler runtime:
|
||||
|
||||
1. Call `dispatch_due_prompts(now)`.
|
||||
2. Surface any sent/failed dispatch results as notifications.
|
||||
3. Refresh session rows and tmux labels using the existing status flow.
|
||||
|
||||
No prompt fires while the TUI process is not running. Overdue prompts fire on
|
||||
the first poll after startup.
|
||||
|
||||
## Dispatch Semantics
|
||||
|
||||
When a pending prompt is due:
|
||||
|
||||
1. Load the target session row.
|
||||
2. Change the prompt status to `sending` and commit before interacting with
|
||||
tmux, so overlapping poll workers cannot inject the same prompt twice.
|
||||
3. If the session row is gone, mark the prompt `failed`.
|
||||
4. If the tmux window is alive, inject directly.
|
||||
5. If the window is dead or missing, resume the session without switching tmux
|
||||
focus, then inject.
|
||||
6. On successful text + Enter injection, mark the row `sent` and set `sent_at`.
|
||||
7. On any failure, mark the row `failed` and store an error message.
|
||||
|
||||
The current `attach_session()` method resumes and then selects the tmux window.
|
||||
Factor an internal helper, `_ensure_session_alive(db, sess) -> bool`, so due
|
||||
prompt dispatch can reuse the resume/fallback ladder without changing the
|
||||
user's active tmux window.
|
||||
|
||||
Failed prompts must not retry automatically on every poll. This avoids sending
|
||||
duplicate prompts or repeatedly restarting a harness after a transient failure.
|
||||
The user can replace the failed prompt by scheduling a new one, or clear it with
|
||||
the cancel action.
|
||||
|
||||
## TUI Details
|
||||
|
||||
Add a new modal screen, `SchedulePromptScreen`, under `src/hqt/tui/screens/`.
|
||||
It should follow existing modal conventions from the project:
|
||||
|
||||
- Plain Textual form controls.
|
||||
- Compact dialog styling using existing CSS patterns.
|
||||
- Inline validation for empty prompt or invalid delay.
|
||||
- OK and Cancel actions.
|
||||
|
||||
Delay parsing is relative-only for the first version. Accepted units are
|
||||
seconds, minutes, and hours. Combined values such as `1h30m` are accepted.
|
||||
Absolute timestamps, recurring prompts, and multiple queued prompts are out of
|
||||
scope.
|
||||
|
||||
`SessionList` row rendering includes pending prompt information when present.
|
||||
Show `@ <countdown>` while the due time is less than 60 minutes away, rounded
|
||||
up to whole minutes, for example `@ 12m`. Show local 24-hour time for prompts
|
||||
that are at least 60 minutes away, for example `@ 14:30`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Scheduling with no selected session shows a warning notification.
|
||||
- Empty prompt text or invalid delay keeps the modal open with an inline error.
|
||||
- Scheduling for a deleted session raises `ServiceError` and shows a TUI error.
|
||||
- Deleted target session at dispatch time marks the prompt failed.
|
||||
- Resume/fallback failure marks the prompt failed with the existing tmux/spawn
|
||||
error when available.
|
||||
- Text or Enter injection failure marks the prompt failed.
|
||||
- Database errors are allowed to surface through existing service error handling.
|
||||
|
||||
## Testing
|
||||
|
||||
Add focused tests at the same layers the current code uses:
|
||||
|
||||
- Model/migration test for the `scheduled_prompts` table and unique
|
||||
`session_id`.
|
||||
- Service tests for schedule, replace, cancel, and retrieving pending prompt
|
||||
metadata in session rows.
|
||||
- Service dispatch tests for alive-window injection, dead-window auto-resume
|
||||
without attach/select, missing session failure, injection failure, and failed
|
||||
prompts not retrying on the next poll.
|
||||
- Tmux tests for manager send delegates and literal send behavior returning
|
||||
success/failure.
|
||||
- TUI tests for the keybindings, modal validation, scheduling action, cancel
|
||||
action, row indicator, and poll-time dispatch notifications.
|
||||
|
||||
After implementation changes, the project checks must pass:
|
||||
|
||||
```bash
|
||||
uv run ruff format src tests
|
||||
uv run ruff check src tests
|
||||
uv run ty check
|
||||
```
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Firing delayed prompts while hqt is closed.
|
||||
- CLI scheduling.
|
||||
- Multiple queued prompts per session.
|
||||
- Absolute date/time scheduling.
|
||||
- Recurring prompts.
|
||||
- Prompt history or a full queue-management screen.
|
||||
- Harness-specific prompt templates.
|
||||
@@ -1,90 +0,0 @@
|
||||
# New Session Dialog UX Refinements — Design
|
||||
|
||||
**Goal:** Three focused UX improvements to the New Session modal: hide sandbox
|
||||
sub-options until sandboxing is enabled, unify the focus highlight to the
|
||||
dialog's Peach accent, and add context-aware j/k field navigation.
|
||||
|
||||
**Scope:** `src/hqt/tui/screens/new_session.py` and `src/hqt/tui/styles.tcss`.
|
||||
No service, DB, or sandbox-policy changes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hide sandbox sub-options until the toggle is on
|
||||
|
||||
The "Filesystem access" and "Network" rows are sub-options of the sandbox
|
||||
switch and should only appear when sandboxing is enabled — mirroring the
|
||||
existing worktree-checkbox → branch-input reveal pattern already in this screen.
|
||||
|
||||
- Wrap the two label/widget pairs (`Filesystem access:` + `#fs-select`,
|
||||
`Network:` + `#net-switch`) in a `Vertical(id="sandbox-options")` container so
|
||||
they toggle as a single unit.
|
||||
- Set `display = False` on the container at compose time.
|
||||
- Add `on_switch_changed(self, event: Switch.Changed)`: when
|
||||
`event.switch.id == "sandbox-switch"`, set `#sandbox-options` `display` to
|
||||
`event.value`.
|
||||
- The sandbox switch starts off — and is `disabled` when bwrap is absent — so
|
||||
the sub-options start hidden in every case. No separate handling for the
|
||||
unavailable case is needed.
|
||||
- `_submit()` still reads `#fs-select` / `#net-switch` unconditionally. Their
|
||||
values are valid defaults (`rw` / net on) even while hidden, and the policy is
|
||||
only built when the sandbox switch is on, so hidden values are never used.
|
||||
|
||||
## 2. Unify the focus highlight to Peach (`$accent`)
|
||||
|
||||
Buttons already get a Peach focus highlight from `styles.tcss`, but
|
||||
`Switch` / `Checkbox` / `Select` / `Input` fall back to Textual's default
|
||||
Lavender (`$border`) focus border. That inconsistency is the "off-palette"
|
||||
highlight. Add scoped rules so every focusable control in the dialog shares the
|
||||
Peach accent:
|
||||
|
||||
```css
|
||||
#new-session-dialog Switch:focus,
|
||||
#new-session-dialog Checkbox:focus,
|
||||
#new-session-dialog Select:focus,
|
||||
#new-session-dialog Input:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
```
|
||||
|
||||
`Switch` / `Checkbox` / `Input` use a `tall` border by default, so matching
|
||||
`tall` keeps layout height identical — only the color changes.
|
||||
|
||||
## 3. Soft context-aware j/k navigation
|
||||
|
||||
Add screen-level bindings that walk the dialog's focus chain:
|
||||
|
||||
```python
|
||||
BINDINGS = [
|
||||
Binding("j", "focus_next", "Next field", show=False),
|
||||
Binding("k", "focus_previous", "Prev field", show=False),
|
||||
]
|
||||
```
|
||||
|
||||
`Input` consumes printable keys before screen bindings fire, so j/k type
|
||||
literally while editing a text field and only move focus when the focused widget
|
||||
is a toggle / select / button — the "soft" (context-aware) model the user chose.
|
||||
`focus_next` / `focus_previous` are built-in Textual screen actions. Tab /
|
||||
Shift+Tab / arrows keep working unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
All three are testable against the screen in isolation via Textual's
|
||||
`run_test()` pilot:
|
||||
|
||||
- **Sub-options reveal:** mount with `sandbox_available=True`; assert
|
||||
`#sandbox-options` `.display` is `False` initially, flip the sandbox switch,
|
||||
assert it becomes `True`, flip back, assert `False`.
|
||||
- **j/k navigation:** assert the `j` / `k` bindings map to
|
||||
`focus_next` / `focus_previous`; drive the pilot to confirm focus moves
|
||||
between toggles and that j/k typed into a focused `Input` land as literal text
|
||||
rather than moving focus.
|
||||
- **Focus highlight:** the color is visual, but the presence of the scoped
|
||||
`:focus` rule is assertable by reading the compiled CSS / `styles.tcss`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No modal (normal/insert) vim mode — the soft model was chosen deliberately.
|
||||
- No change to Enter-to-submit behavior.
|
||||
- No restyling of controls outside the New Session dialog.
|
||||
@@ -1,134 +0,0 @@
|
||||
# Reset tmux window indexes
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Add reusable functionality that compacts tmux window indexes inside the `hqt`
|
||||
tmux session so keyboard shortcuts such as `Alt+1`, `Alt+2`, and `Alt+3` stay
|
||||
useful after windows have been closed.
|
||||
|
||||
This reset affects only tmux window indexes. It does not change database session
|
||||
IDs, `tmux_session_name`, harness conversation IDs, nicknames, panes, or running
|
||||
processes.
|
||||
|
||||
## Behavior
|
||||
|
||||
The reset operation works on the configured `hqt` tmux session:
|
||||
|
||||
- The home/TUI window always ends at tmux index `0`.
|
||||
- Every non-home window keeps its current name, pane, process, and content.
|
||||
- Non-home windows are sorted by their current tmux index.
|
||||
- Those non-home windows are moved into contiguous indexes starting at `1`.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
0: ⌂ HQT
|
||||
1: hqt-foo
|
||||
4: hqt-bar
|
||||
9: hqt-baz
|
||||
```
|
||||
|
||||
becomes:
|
||||
|
||||
```text
|
||||
0: ⌂ HQT
|
||||
1: hqt-foo
|
||||
2: hqt-bar
|
||||
3: hqt-baz
|
||||
```
|
||||
|
||||
If the home window is not already at `0`, it is moved to `0` first, then the
|
||||
remaining windows are compacted after it in their original index order.
|
||||
|
||||
## Architecture
|
||||
|
||||
Add the reset at the tmux layer rather than the session or database layer.
|
||||
Existing app and service code targets windows by name, so changing tmux indexes
|
||||
should not disturb attach, stop, delete, status polling, or label syncing.
|
||||
|
||||
### Runner
|
||||
|
||||
Add a low-level method on `TmuxRunner`, for example:
|
||||
|
||||
```python
|
||||
async def reset_window_indexes(self, home_window: str) -> bool:
|
||||
...
|
||||
```
|
||||
|
||||
The method should:
|
||||
|
||||
1. List windows for `self.session_name` with `#{window_id}`,
|
||||
`#{window_index}`, and `#{window_name}`.
|
||||
2. Find the row whose name matches `home_window`.
|
||||
3. Move the home window to index `0` if needed.
|
||||
4. Sort all other windows by their original index.
|
||||
5. Move each non-home window to `1..N` in that order.
|
||||
|
||||
Use `move-window` with `window_id` as the source target so changing one index
|
||||
cannot make a later move target the wrong window. The destination should target
|
||||
the configured tmux session and explicit index.
|
||||
|
||||
The implementation should be idempotent: running it against an already compact
|
||||
session should return success without changing anything meaningful.
|
||||
|
||||
### Manager
|
||||
|
||||
Add a thin delegate on `TmuxManager`, for example:
|
||||
|
||||
```python
|
||||
async def reset_window_indexes(self, home_window: str) -> bool:
|
||||
return await self.runner.reset_window_indexes(home_window)
|
||||
```
|
||||
|
||||
No `SessionService` change is needed for the initial implementation. This is a
|
||||
tmux maintenance operation, not a session lifecycle operation.
|
||||
|
||||
### Command Surface
|
||||
|
||||
Implement the reusable operation now and defer user-facing wiring. A command
|
||||
palette is planned separately, so this feature should expose a callable method
|
||||
that the future command can invoke.
|
||||
|
||||
Do not add a TUI keybinding or footer item in this change.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If the configured tmux session does not exist or cannot be listed, return
|
||||
failure and leave the database untouched.
|
||||
- If the home window is missing, return failure rather than guessing which
|
||||
window should become index `0`.
|
||||
- If a `move-window` command fails, return failure. The operation may have
|
||||
partially moved tmux windows before the failure; this is acceptable because
|
||||
tmux indexes are maintenance metadata and no persisted hqt state is changed.
|
||||
- Log tmux stderr for failed operations to aid debugging.
|
||||
|
||||
## Testing
|
||||
|
||||
Add focused tests in `tests/test_tmux.py`:
|
||||
|
||||
- Missing home window returns failure and performs no moves.
|
||||
- Already compact windows return success.
|
||||
- Sparse indexes compact in current index order:
|
||||
`0 home, 1 foo, 4 bar, 9 baz` moves `bar` to `2` and `baz` to `3`.
|
||||
- Home initially not at `0` is moved to `0`, and all other windows compact to
|
||||
`1..N` in original order.
|
||||
- Moves target stable window identities, not mutable index-only targets.
|
||||
|
||||
The implementation must pass the project checks after code changes:
|
||||
|
||||
```bash
|
||||
uv run ruff format src tests
|
||||
uv run ruff check src tests
|
||||
uv run ty check
|
||||
```
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changing database primary keys.
|
||||
- Changing `Session.tmux_session_name`.
|
||||
- Changing harness session IDs or resume behavior.
|
||||
- Adding command palette UI, keybindings, or footer labels.
|
||||
- Enabling permanent tmux automatic renumbering.
|
||||
Reference in New Issue
Block a user