diff --git a/docs/superpowers/plans/2026-06-10-tool-windows.md b/docs/superpowers/plans/2026-06-10-tool-windows.md new file mode 100644 index 0000000..a10dd42 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-tool-windows.md @@ -0,0 +1,961 @@ +# 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 ` (builds the fzf `display-popup`) and `hqt tool ` (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 ` 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 " +``` + +--- + +## 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 ...` 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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- 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 " +``` + +--- + +## 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- 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 " +``` + +--- + +## 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 ` 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 `. `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 {} ` + 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 `. + """ + 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 " +``` + +--- + +## 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- 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-` 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-` 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. diff --git a/docs/superpowers/specs/2026-06-10-sandboxed-sessions-design.md b/docs/superpowers/specs/2026-06-10-sandboxed-sessions-design.md new file mode 100644 index 0000000..27ac26a --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-sandboxed-sessions-design.md @@ -0,0 +1,204 @@ +# 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 … -- ` 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. diff --git a/docs/superpowers/specs/2026-06-10-tool-windows-design.md b/docs/superpowers/specs/2026-06-10-tool-windows-design.md new file mode 100644 index 0000000..d92f90f --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-tool-windows-design.md @@ -0,0 +1,269 @@ +# 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-` 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 " | 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 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 : -n -c -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