From b0011e7c1e38ada68a0080ab1a4c5ee79cf4b7d4 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 16:27:53 -0700 Subject: [PATCH 01/95] docs(superpowers): add interactive-claude tmux/sidecar design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for a new experimental agent backend that runs interactive `claude` (no -p) inside a detachable host — tmux on Unix, a custom Rust `claudette-session-host` sidecar on Windows. Coexists with the existing print-mode path behind a `claudeInteractiveEnabled` Settings flag. Renderer perf is explicitly deferred to a follow-up spec. Co-Authored-By: Claude Opus 4.7 --- ...26-05-16-claude-interactive-tmux-design.md | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 superpowers/specs/2026-05-16-claude-interactive-tmux-design.md diff --git a/superpowers/specs/2026-05-16-claude-interactive-tmux-design.md b/superpowers/specs/2026-05-16-claude-interactive-tmux-design.md new file mode 100644 index 000000000..39ac83034 --- /dev/null +++ b/superpowers/specs/2026-05-16-claude-interactive-tmux-design.md @@ -0,0 +1,335 @@ +# Interactive Claude Sessions (tmux / Sidecar Host) — Design + +- **Date:** 2026-05-16 +- **Status:** Design — pending implementation plan +- **Scope:** Add a new agent backend that runs `claude` interactively in a detachable host (tmux on Unix, custom Rust sidecar on Windows), gated behind an experimental flag. Coexists with the existing `claude --print` path; does not replace it. + +## Goals + +1. **Detachability / resilience.** A Claude session survives Claudette closing, crashing, or restarting. On Unix, the same session is reachable from an external terminal via `tmux attach`. +2. **Full Claude Code TUI parity.** Run the interactive `claude` binary, not `claude --print`. Pick up features that the interactive client gets first (slash-command UI, plan-mode polish, native permission prompts, hooks). +3. **Foundation for renderer perf.** The protocol leaves explicit room for swapping byte-streaming for grid-diff streaming and for native renderers later, but renderer work is **not** in this spec. + +## Non-goals (v1) + +- Replacing the `claude --print` backend. The new backend lives alongside. +- Native terminal widgets (libGhostty, wezterm-gui, alacritty_terminal as a renderer). Renderer perf is a separate follow-up spec. +- Sharing or transferring running sessions between workspaces or users. +- Remote (over-WebSocket) interactive sessions. Sidecar IPC is local-only in v1. +- Subagents-as-interactive-sessions. Each subagent today is its own `claude -p` and stays that way. + +## Decision summary + +| Decision | Choice | Why | +|---|---|---| +| Replace `-p` or add alongside? | **Add alongside**, opt-in via experimental flag | Existing `-p` flow is stable; new code path de-risks rollout. | +| Host implementation | **tmux on Unix + custom Rust sidecar on Windows** | Real tmux on Unix gives users `tmux attach` for free; Windows has no tmux. Two code paths accepted, capped by a shared trait-conformance test suite. | +| Awaiting-input signal | **Claude Code hooks** (`Stop`, `Notification`, `UserPromptSubmit`) | Structured, official, version-stable; no fragile output parsing. | +| Streaming format in v1 | **Raw VT bytes + interleaved hook events** | Smallest delta to existing xterm.js infrastructure. Grid-diff path is a future addition, not a rewrite. | +| Renderer | **Existing xterm.js (no WebGL upgrade required in this spec)** | Renderer perf is its own spec. Protocol designed to allow swap later. | +| Experimental flag | `claudeInteractiveEnabled` in Settings → Experimental | Matches existing `pluginManagementEnabled` shape. | + +## Architecture + +### New crate + +A new workspace member: **`claudette-session-host`** at `src-session-host/`. Sibling to `claudette-cli` and `claudette-server`. Built for all platforms (gives Unix users a fallback if tmux is unavailable, and gives the test suite a single binary to drive on every CI runner). Bundled into the Tauri app via `bundle.externalBin`, same mechanism as `claudette-pi-harness`. + +The sidecar owns interactive Claude PTYs via `portable-pty` and exposes a line-framed JSON protocol over a Unix domain socket / Named Pipe. It maintains an in-memory grid model per session via `alacritty_terminal` (used only for `capture_screen` snapshots in v1; full grid-diff streaming is deferred). + +### Backend kind and routing + +A new agent backend kind `ClaudeInteractive` is added to `src/agent/harness.rs`. Routing in `src/agent_backend.rs::effective_harness` recognises the kind and dispatches to a new module `src/agent/claude_interactive.rs` instead of the existing `src/agent/process.rs` `--print` path. The kind is only selectable when `claudeInteractiveEnabled = true`. + +### `InteractiveHost` trait + +`src/agent/interactive_host/mod.rs` defines: + +```rust +#[async_trait] +pub trait InteractiveHost: Send + Sync { + async fn ensure_session(&self, sid: &SessionId, spec: &SessionSpec) -> Result; + async fn attach(&self, sid: &SessionId) -> Result; // streams OutputDelta + HookFired + async fn send_input(&self, sid: &SessionId, payload: InputPayload) -> Result<()>; + async fn capture_screen(&self, sid: &SessionId) -> Result; + async fn resize(&self, sid: &SessionId, rows: u16, cols: u16) -> Result<()>; + async fn detach(&self, sid: &SessionId, attach_id: AttachId) -> Result<()>; + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<()>; + async fn status(&self) -> Result; +} +``` + +Two implementations: + +- **`src/agent/interactive_host/tmux.rs`** (Unix only). Shells out to `tmux` via `tokio::process::Command`. Operations map as documented in the protocol section below. +- **`src/agent/interactive_host/sidecar.rs`** (all platforms). Connects to the bundled `claudette-session-host` over the IPC transport. + +A shared conformance test suite (`src/agent/interactive_host/conformance.rs`) takes any `InteractiveHost` and runs the full lifecycle test. Both impls must pass it. + +### Host selection at runtime + +- **Windows:** always sidecar. +- **Unix:** prefer tmux if `which tmux` succeeds AND `tmux -V` reports ≥ 3.0. Otherwise fall back to sidecar (only if explicitly enabled by the user in Settings; do not silently switch hosts). +- Availability is cached with a 30-second TTL and re-checked at each `ensure_session` and on every reattach. + +If a workspace has rows with `host_kind = "tmux"` and tmux is no longer available at startup, those rows transition to `state = "crashed"` with `crash_reason = "tmux unavailable"`. The UI surfaces an "install tmux" hint with platform-specific commands. + +### Session identity + +``` +SessionId = "claudette--" +``` + +`sid8` is 8 hex chars from a CSPRNG. The session name is identical between tmux's session-name namespace and the sidecar's session-key namespace — making `tmux attach -t ` directly usable on Unix. + +## Sidecar wire protocol + +Length-prefixed JSON-line frames over `interprocess::local_socket` (Unix domain socket on macOS/Linux, Named Pipe on Windows). Same crate already used by `claudette-cli`. + +Default socket paths: +- Unix: `${TMPDIR:-/tmp}/claudette-session-host/.sock` +- Windows: `\\.\pipe\claudette-session-host-` + +### Handshake + +Every new connection first exchanges: + +```jsonc +// client → server +{ "kind": "hello", "protocol_version": 1, "claudette_version": "..." } +// server → client +{ "kind": "hello_ack", "protocol_version": 1, "host_version": "...", "pid": 12345 } +``` + +Version mismatch returns `hello_nack { "reason": "...", "supported_versions": [1] }` and the client surfaces a "restart Claudette to update the session host" error. + +### Request kinds + +| Kind | Payload | Response | Notes | +|---|---|---|---| +| `ensure_session` | `{sid, spec: SessionSpec}` | `SessionStarted{sid, pid, rows, cols}` | Idempotent. Returns existing if already running. | +| `attach` | `{sid}` | streaming `OutputDelta` + `HookFired` events until detach | Multiple concurrent attaches per session allowed. | +| `send_input` | `{sid, payload: InputPayload}` | `Ok` | `InputPayload` = `Text{string}` or `Keys{name}` (e.g. `"C-c"`). | +| `capture_screen` | `{sid}` | `ScreenSnapshot{rows, cols, ansi_bytes}` | v1 returns ANSI replay bytes; v2 may add `cells`. | +| `resize` | `{sid, rows, cols}` | `Ok` | | +| `detach` | `{sid, attach_id}` | `Ok` | | +| `stop` | `{sid, mode: "graceful"|"force"}` | `Stopped{exit_status}` | Graceful = `C-c`, then SIGTERM after 5s, then SIGKILL. | +| `status` | `{}` | `HostStatus{sessions: [...], host_version}` | | + +### Streamed events (over an open `attach`) + +| Kind | Payload | +|---|---| +| `output` | `OutputDelta{bytes: base64, seq: u64}` | +| `hook` | `HookFired{kind: "stop"|"awaiting"|"prompt_submitted"|..., payload: opaque}` | +| `exit` | `SessionExited{sid, exit_status, reason}` | +| `error` | `StreamError{message, recoverable: bool}` | + +`seq` is monotonic per session and per attach so clients can detect dropped frames. + +## tmux mapping (Unix) + +| Trait op | tmux command | +|---|---| +| `ensure_session` | `tmux new-session -d -s -x -y -e CLAUDE_CONFIG_DIR= claude ` | +| `attach` | `tmux pipe-pane -O -t 'cat >> $output_fifo'` (once per session), then tail the fifo per Claudette-side attach | +| `send_input` (text) | `tmux send-keys -t -l -- ` | +| `send_input` (key) | `tmux send-keys -t -- ` (e.g. `C-c`, `Enter`) | +| `capture_screen` | `tmux capture-pane -t -pJ -e` | +| `resize` | `tmux resize-window -t -x -y ` | +| `stop` (graceful) | `send-keys C-c` → wait 5s → `kill-session` | +| `stop` (force) | `tmux kill-session -t ` | +| `status` | `tmux list-sessions -F '#{session_name}|#{session_created}|#{session_attached}'`, filtered to `claudette-…` prefix | + +The `pipe-pane` output FIFO lives under `${TMPDIR}/claudette-session-host//.fifo`. Claudette tails it; each tailer is one "attach" in `InteractiveHost` terms. + +A periodic 30-second reconciliation job (`tmux list-sessions`) cross-checks our DB against reality and flips state for sessions killed externally. + +## Awaiting-input signal — Claude Code hooks + +At `ensure_session` time, Claudette materialises a per-session settings overlay directory at `${TMPDIR}/claudette-session-host///claude-config/` and exports `CLAUDE_CONFIG_DIR` pointing at it when spawning the `claude` process. This keeps the overlay transient and per-session — we never write to the user's real `~/.claude/` or to `.claude/settings.local.json`. The overlay directory is removed when the session is stopped. + +The overlay registers hooks whose command strings use the **absolute path** to the bundled `claudette-cli` binary, resolved once at sidecar / Claudette startup (via `tauri::path::resolve_resource` or the CLI's `current_exe` lookup) and burned into the rendered `settings.json`. We do not assume `claudette-cli` is on `PATH` inside the spawned `claude` process. + +| Hook | Maps to | +|---|---| +| `Stop` | ` chat hook --sid $SID --kind stop` | +| `Notification` | ` chat hook --sid $SID --kind awaiting --reason "$REASON"` | +| `UserPromptSubmit` | ` chat hook --sid $SID --kind prompt_submitted` | +| `SubagentStop` *(optional)* | ` chat hook --sid $SID --kind subagent_stop` | + +`claudette-cli chat hook` is a new subcommand that writes the structured event to the existing CLI IPC socket. The running GUI's IPC server ingests hook events the same way it ingests other CLI commands today (`src-tauri/src/ipc.rs`). No new transport. + +**Schema fallback.** Each incoming hook payload is parsed against a versioned schema. On parse failure, we still emit a degraded `HookFired{kind: "unknown_hook", payload: raw_text}` event so the UI doesn't lock up, log a warning, and continue. Anthropic-side schema drift becomes a visible warning, not a silent break. + +**Watchdog (deferred).** A future `tcgetattr`-based TTY-mode poll could detect "claude is in input state" as a coarse-grained fallback if hooks fail to fire. Not in v1. + +## UI integration + +### Experimental flag + +A new boolean in `app_settings`: `claudeInteractiveEnabled`. Rendered in **Settings → Experimental** alongside `pluginManagementEnabled`. Default `false`. When false, the "Claude (Interactive)" runtime card in Models → Runtime is greyed out with "Enable in Experimental" text. + +### Backend selection + +When the user picks "Claude (Interactive)" as a workspace's runtime, the chat-send resolver routes new turns to `claude_interactive.rs`. The first turn calls `ensure_session` lazily; subsequent turns call `send_input` with the user's text plus a carriage return. + +### Chat surface + +Two coexisting modes, both backed by the same session: + +1. **In-chat embedded view (default).** Each active turn renders a small xterm.js instance inside its chat bubble, scoped to the bytes between `UserPromptSubmit` and `Stop` hook events (or the live cursor if mid-turn). Completed turns collapse to a static rendered transcript so the chat panel still feels like chat. +2. **Full-terminal view.** A chat-header toggle swaps the chat panel into a full-size xterm.js attached to the same session. Useful for plan mode, native permission prompts, debugging. Switching back keeps the session running. State is per-workspace and persists across reloads. + +Both modes reuse the existing xterm.js setup; no new terminal renderer or new dependency in v1. + +### Awaiting-input indicator + +`Notification:awaiting` hook → set the session's badge to "Awaiting input" using the existing attention plumbing (the same surface that lights up for AskUserQuestion / ExitPlanMode in `-p` mode today). Fire the existing OS notification path via `tray.rs` (sound, click-to-navigate). The badge clears on `UserPromptSubmit` or after `Stop`. + +No new notification infrastructure. + +### Detach / reattach + +- **Close Claudette with a running session.** Session stays alive in the host. DB row stays with `state = "running"`; sidebar shows "Detached" on next launch. +- **Reopen Claudette.** Boot path calls `interactive_host::status()` (per-host) and reconciles with DB. For each surviving session, we `capture_screen` to paint instantly, then `attach` for live updates. User sees a "Reattached" toast. +- **Power-user external attach (Unix only).** Session row's context menu copies `tmux attach-session -t `. Documented warning the first time: typing in an external attach can confuse Claudette's turn assembly because input bypasses `UserPromptSubmit` synthesis. Accepted v1 degradation. +- **Stop session button.** Confirm dialog → `stop` with `mode = "graceful"`. "Force kill" link in the dialog for `mode = "force"`. + +## Persistence + +A new migration `src/migrations/_interactive_sessions.sql`: + +```sql +CREATE TABLE IF NOT EXISTS interactive_sessions ( + sid TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + host_kind TEXT NOT NULL, -- "tmux" | "sidecar" + state TEXT NOT NULL, -- "running" | "detached" | "stopped" | "crashed" + crash_reason TEXT, + created_at TEXT NOT NULL, + last_attached_at TEXT, + last_screen_blob BLOB, -- ANSI replay bytes for instant repaint + claude_flags_json TEXT NOT NULL, + pid INTEGER, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_interactive_sessions_workspace + ON interactive_sessions(workspace_id); +``` + +`last_screen_blob` is ANSI bytes (the result of `capture_screen`) so reattach can paint instantly while the live stream catches up. Stored as a SQLite BLOB; an alternative is a sidecar file at `~/.claudette/sessions/.screen`. Open question — leaning toward BLOB for v1 (one fewer thing to clean up). + +## Lifecycle and crash semantics + +### Sidecar process lifecycle + +- Launched lazily on first `ensure_session` call. +- Listens on the per-user socket path described above. +- Self-exits after **600 seconds** of zero live sessions AND zero connected clients (configurable). +- Survives Claudette quitting. On Claudette restart, the existing socket is detected via `status` round-trip; if reachable, we reuse it; if not, we launch a fresh sidecar. + +### tmux server lifecycle + +We do not own the tmux server. We use whatever the user runs. Our session names live entirely under the `claudette-…` prefix and we never operate on sessions outside that prefix. + +### Crash matrix + +| Failure | Behaviour | +|---|---| +| `claude` subprocess dies | `Stop` hook fires (or `exit` event from host); session row marked `state = "stopped"`; UI offers "Start a fresh session" or "Resume with `/resume`". | +| tmux server dies | All our tmux sessions die. We mark them `state = "crashed"` with `crash_reason = "tmux server died"`. Rare. | +| Sidecar dies | All sidecar sessions die. Same treatment. Sidecar relaunches on the next `ensure_session`. | +| Claudette GUI quits | Nothing dies in the host. Reattach on next launch. | + +### Cleanup + +- Per-workspace: `workspaces ON DELETE CASCADE` removes the rows; before delete, we call `stop` on each running session to also kill it in the host. +- Orphaned host-side sessions (host sees a `claudette-…` session that our DB doesn't know about): logged on startup, surfaced as a "found N orphaned sessions" toast with a "clean up" action. We never auto-kill. + +## Testing + +### Rust unit tests +- Wire-protocol serde round-trips for every request/response/event kind, including partial-read framing and oversized frames. +- `InteractiveHost` trait conformance suite (`src/agent/interactive_host/conformance.rs`) parameterised by host impl. Tests start, send, capture, detach, reattach, stop, status enumeration, idempotent double-start. +- Hook ingestion state machine: given a sequence of hook events, asserts session state transitions. + +### Sidecar integration tests +- A small stub TUI program at `tests/fixtures/stub-tui/` (Rust binary). Echoes input lines with a prompt sentinel, supports an env-var-driven fake `Notification` hook, exits on `q\n`. Used in place of real `claude` so CI doesn't need the binary. +- Workspace-level integration tests at `tests/interactive_host_sidecar.rs` spawn a real `claudette-session-host` against the stub TUI on macOS, Linux, and Windows. + +### tmux integration tests (Unix only) +- Same conformance suite at `tests/interactive_host_tmux.rs`, `#[cfg(unix)]`. CI Linux + macOS runners already have tmux; if absent, the test is `#[ignore]`d with a clear message. +- `TMUX_TMPDIR` per test to isolate concurrent runs. + +### Frontend tests (vitest) +- Hook-delimited turn assembler given a sequence of `OutputDelta` + `HookFired` events. +- Backend-selector UI behaviour for the experimental flag, including the tmux-missing warning. +- Reattach toast logic on store hydrate. + +### Acceptance gaps (documented) +- No real `claude` binary in CI; stub TUI is the contract. Real-binary smoke tests are manual + a nightly job (not blocking). +- External `tmux attach` from another terminal is not tested; warning-only mitigation. + +## Phasing + +### v1 (this spec) +1. `claudette-session-host` crate + Tauri externalBin bundling. +2. `InteractiveHost` trait + tmux + sidecar impls. +3. New backend kind `ClaudeInteractive`, gated by `claudeInteractiveEnabled`. +4. Settings overlay that registers Claude Code hooks routing to `claudette-cli chat hook`. +5. Migration for `interactive_sessions` table. +6. ChatPanel embedded turn view + full-terminal mode toggle. +7. Sidebar "Awaiting input", "Detached", and "Crashed" states. +8. Reattach-on-startup with `last_screen_blob` instant paint. +9. `tmux attach -t …` copy-string on Unix. +10. Test harness: stub TUI, conformance suite, frontend tests. +11. Docs: `site/src/content/docs/features/interactive-claude.mdx`, `settings.mdx` row, `CLAUDE.md` + `.github/copilot-instructions.md` sync. + +### Deferred (named follow-up specs) +- **Renderer perf.** Move VT parsing fully server-side; ship grid-diff events; xterm.js WebGL addon; later possibly libGhostty / wezterm-gui native widget. +- **Multi-attach safety.** Mirror external-`tmux attach` user input into synthetic `UserPromptSubmit` events so turn assembly stays coherent. +- **Remote interactive sessions.** Sidecar protocol over the existing `claudette-server` WebSocket transport. +- **Subagents as interactive sessions.** Each subagent gets its own host-managed pane. +- **TTY-mode watchdog.** Coarse-grained fallback signal if hooks fail to fire. + +## Risks + +1. **Hook schema drift.** Anthropic changes a hook event shape; turn assembly breaks. Mitigation: per-kind versioned parsers, fall back to `unknown_hook` raw text on parse failure, surface a warning. +2. **macOS TCC.** `claudette-session-host` is a new binary path; TCC bindings can be granular. Verify before shipping that no new privacy prompts fire at launch (we already spawn PTYs for the existing terminal panel, so this should be neutral). +3. **Named Pipe reliability on Windows.** Different reconnect semantics from Unix sockets. Stress-test rapid Claudette restarts; sidecar must handle "client disappeared without proper detach" cleanly. +4. **Two-code-path drift.** The whole point of B is paying this cost knowingly. The conformance test suite is the structural mitigation; every protocol change must land in both impls or the suite fails. + +## Open questions (flagged, not blockers) + +- Sidecar log location: own file at `~/.claudette/logs/session-host..log`, or pipe through Claudette's tracing? Lean: own file (survives Claudette closing). +- `last_screen_blob` as ANSI bytes vs serialised cell grid? Lean: ANSI bytes (lighter, lossier but adequate). +- Sidecar-protocol version mismatch handling: blocker or auto-restart sidecar? Lean: blocker with "restart Claudette to update" message; auto-restart could mask real bugs. +- **Attachments in interactive mode.** The existing `-p` path uses `--input-format stream-json` on stdin to send rich user payloads (images, files). Interactive `claude` reads from a PTY and does not accept stream-json the same way. v1 likely treats the user input as plain typed text + relies on interactive claude's own slash-command paste-file flow (`/file …`, drag-and-drop UI hooks where supported). Implementor should verify what the current interactive client supports and design the UI affordance to match; if attachments cannot be supported well, surface that limitation in the experimental-flag description so users know what they're trading off. + +## File summary + +### New +- `src-session-host/` — new workspace crate (`claudette-session-host`). +- `src/agent/claude_interactive.rs` +- `src/agent/interactive_host/{mod,tmux,sidecar,conformance}.rs` +- `tests/interactive_host_sidecar.rs` +- `tests/interactive_host_tmux.rs` (`#[cfg(unix)]`) +- `tests/fixtures/stub-tui/` — Rust binary used by the integration tests. +- `src/ui/src/components/chat/InteractiveTurnView.tsx` +- `src/migrations/_interactive_sessions.sql` +- `site/src/content/docs/features/interactive-claude.mdx` + +### Modified +- `Cargo.toml` — register new workspace member. +- `src/agent/harness.rs` — new `ClaudeInteractive` kind. +- `src/agent_backend.rs` — resolver branch. +- `src-tauri/Cargo.toml` + `src-tauri/tauri.conf.json` — bundle the sidecar via `externalBin`. +- `src-tauri/src/commands/chat.rs` — turn-assembly wiring for interactive backend. +- `src-tauri/src/ipc.rs` — hook event ingestion. +- `src/migrations/mod.rs` — register the new migration. +- `src/ui/src/components/chat/ChatPanel.tsx` — conditional render based on backend kind. +- `src/ui/src/components/settings/sections/ExperimentalSettings.tsx` — new flag row. +- `site/src/content/docs/features/settings.mdx` — settings reference row. +- `site/astro.config.mjs` — sidebar entry for the new docs page. +- `CLAUDE.md` + `.github/copilot-instructions.md` — sync per the alignment rule. From 964f8b7cdd60655b393f53c2015eb3c0124b7641 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 16:46:17 -0700 Subject: [PATCH 02/95] docs(superpowers): add interactive-claude tmux/sidecar implementation plan Bite-sized TDD plan covering Phases A-I: settings flag + migration, InteractiveHost trait + conformance suite, claudette-session-host sidecar crate, TmuxHost impl, hooks + CLI subcommand + IPC ingestion, backend wiring + Tauri commands, UI surfaces, lifecycle (reattach + cleanup + orphans), and docs/CLAUDE.md sync. Co-Authored-By: Claude Opus 4.7 --- ...-claude-interactive-tmux-implementation.md | 4110 +++++++++++++++++ 1 file changed, 4110 insertions(+) create mode 100644 superpowers/plans/2026-05-16-claude-interactive-tmux-implementation.md diff --git a/superpowers/plans/2026-05-16-claude-interactive-tmux-implementation.md b/superpowers/plans/2026-05-16-claude-interactive-tmux-implementation.md new file mode 100644 index 000000000..4c44e8d88 --- /dev/null +++ b/superpowers/plans/2026-05-16-claude-interactive-tmux-implementation.md @@ -0,0 +1,4110 @@ +# Interactive Claude Sessions (tmux / sidecar host) 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. + +**Spec:** `superpowers/specs/2026-05-16-claude-interactive-tmux-design.md` (commit `f346b37`). Read it first if you have not. + +**Goal:** Add a new experimental `ClaudeInteractive` agent backend that runs interactive `claude` inside a detachable host — tmux on Unix, a custom Rust sidecar (`claudette-session-host`) on Windows — alongside the existing `claude --print` flow. + +**Architecture:** A new `InteractiveHost` async trait (`src/agent/interactive_host/`) with two impls (`tmux.rs`, `sidecar.rs`). A new workspace crate `claudette-session-host` provides the Windows sidecar. New types flow through `AgentHarnessKind::ClaudeInteractive` + `AgentSession::ClaudeInteractive(...)`. UI gated behind `claudeInteractiveEnabled` experimental flag. Hooks (`Stop`, `Notification`, `UserPromptSubmit`) flow back through a new `claudette-cli chat hook` subcommand into the existing IPC server. + +**Tech Stack:** Rust (workspace, Tokio, `portable-pty`, `interprocess`, `alacritty_terminal`, `serde_json`, `tokio::process`), TypeScript (React, Zustand, xterm.js), SQLite (`rusqlite`), tmux ≥ 3.0 (Unix only). + +**Conventions to follow:** +- Conventional commits (`feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`). +- `cargo fmt --all`, `cargo clippy -p claudette -p claudette-server -p claudette-cli -p claudette-session-host --all-targets --all-features` must pass with **zero warnings** (`RUSTFLAGS="-Dwarnings"` in CI). +- `cd src/ui && bunx tsc -b && bun run lint && bun run lint:css && bun run test` — run all four before any frontend commit. +- All migration files use `YYYYMMDDHHMMSS_snake_case.sql` named with the current UTC timestamp at the moment you author the file (`date -u +%Y%m%d%H%M%S`). The plan uses `` as a placeholder — substitute when you create the file and use the same value in `MIGRATIONS`. +- `apiVersion 63.0` in cls-meta.xml is not relevant here (no Salesforce). +- Every Rust public item gets a doc comment. +- TypeScript: strict mode, no `any`. Vitest, not Jest. `bun`, not npm. +- All UI colors are CSS custom-property references (`var(--token-name)`) — no raw hex/rgba outside `theme.css`. +- Keep god files small: `TerminalPanel.tsx`, `ChatPanel.tsx`, `ChatInputArea.tsx`, `sidebar/Sidebar.tsx`, `services/tauri.ts`. Prefer new files over piling onto these. + +**Test contract:** every step that says "Run …" expects the command to exit 0 with the documented output. Steps that say "Expected: FAIL with …" expect non-zero exit and the documented substring in stderr. + +--- + +## File map + +### New files +| Path | Responsibility | +|---|---| +| `src/agent/interactive_host/mod.rs` | `InteractiveHost` trait, shared types (`SessionId`, `SessionSpec`, etc.), host-selection function. | +| `src/agent/interactive_host/types.rs` | Data types: `SessionId`, `SessionSpec`, `HostHandle`, `AttachStream`, `InputPayload`, `OutputDelta`, `HookFired`, `HookKind`, `ScreenSnapshot`, `HostStatus`, `StopMode`. | +| `src/agent/interactive_host/tmux.rs` | `TmuxHost` impl (`#[cfg(unix)]`). | +| `src/agent/interactive_host/sidecar.rs` | `SidecarHost` impl (all platforms). Owns the local-socket connection. | +| `src/agent/interactive_host/conformance.rs` | `pub async fn run(host: H, fixture: TestFixture)` — the shared test suite both impls must pass. | +| `src/agent/interactive_host/availability.rs` | Tmux/sidecar availability checks (with 30s TTL cache). | +| `src/agent/claude_interactive.rs` | The `ClaudeInteractive` backend module: turn execution, hook ingestion, settings-overlay materialization. | +| `src/agent/interactive_protocol.rs` | Wire protocol types shared between Rust client (`SidecarHost`) and the `claudette-session-host` server. Length-prefixed JSON-line frames. | +| `src/migrations/_interactive_sessions.sql` | The new table. | +| `src-session-host/Cargo.toml` | The new crate manifest. | +| `src-session-host/src/main.rs` | Sidecar binary entry point. | +| `src-session-host/src/server.rs` | Local-socket server, per-connection task. | +| `src-session-host/src/session.rs` | Per-session PTY + grid model + attach broadcast. | +| `src-session-host/src/idle.rs` | Idle-exit timer. | +| `tests/interactive_host_sidecar.rs` | Sidecar conformance test (all platforms). | +| `tests/interactive_host_tmux.rs` | Tmux conformance test (`#[cfg(unix)]`). | +| `tests/fixtures/stub-tui/Cargo.toml` | Stub-TUI manifest. | +| `tests/fixtures/stub-tui/src/main.rs` | Stub TUI binary. | +| `src-cli/src/commands/chat_hook.rs` | `claudette-cli chat hook` subcommand. | +| `src/ui/src/components/chat/InteractiveTurnView.tsx` | Per-turn embedded xterm.js view. | +| `src/ui/src/components/chat/InteractiveTerminalMode.tsx` | Full-terminal mode of the chat panel. | +| `src/ui/src/hooks/useInteractiveTurnAssembler.ts` | Hook-delimited turn assembler. | +| `src/ui/src/services/interactive.ts` | Tauri bridge for interactive sessions (`startInteractive`, `attach`, `sendInput`, `stop`, etc.). | +| `src/ui/src/components/chat/InteractiveTurnView.test.tsx` | Turn-assembler tests. | +| `site/src/content/docs/features/interactive-claude.mdx` | User-facing docs page. | + +### Modified files +| Path | What changes | +|---|---| +| `Cargo.toml` | Register `src-session-host` and `tests/fixtures/stub-tui` workspace members; add `alacritty_terminal` and `interprocess` to workspace deps if not already present. | +| `src/agent/mod.rs` | `pub mod interactive_host;` + `pub mod claude_interactive;` + re-exports. | +| `src/agent/harness.rs` | Add `ClaudeInteractive` variant to `AgentHarnessKind`, `AgentSession`, capabilities for it. | +| `src/agent_backend.rs` | New backend-kind enum value + resolver branch. | +| `src/migrations/mod.rs` | Append the new `MIGRATIONS` entry. | +| `src/db.rs` | New CRUD helpers `interactive_session_*`. | +| `src/lib.rs` | Re-export `interactive_host` and `claude_interactive` if needed by tests. | +| `src-cli/src/main.rs` and `src-cli/src/commands/mod.rs` | Register the new `chat hook` subcommand. | +| `src-tauri/Cargo.toml` | Add `claudette-session-host` as a workspace dep; add `bundle.externalBin` entry. | +| `src-tauri/tauri.conf.json` | Bundle the sidecar binary. | +| `src-tauri/src/commands/chat.rs` | Dispatch interactive backends; emit interactive events. | +| `src-tauri/src/commands/settings.rs` | Wire the new experimental flag setter/getter (boolean app_setting). | +| `src-tauri/src/commands/mod.rs` and a new `src-tauri/src/commands/interactive.rs` | Tauri commands for interactive sessions. | +| `src-tauri/src/ipc.rs` | Ingest hook events from the CLI. | +| `src-tauri/src/state.rs` | Store per-workspace `InteractiveHost` selection (Arc-ed). | +| `src/ui/src/components/chat/ChatPanel.tsx` | Conditional render: interactive vs print-mode. | +| `src/ui/src/components/chat/ChatHeader.tsx` (or wherever the chat header lives) | "Open in Terminal" toggle. | +| `src/ui/src/components/sidebar/Sidebar.tsx` | Badge states: Awaiting input / Detached / Crashed. | +| `src/ui/src/components/settings/sections/ExperimentalSettings.tsx` | New flag row. | +| `src/ui/src/components/settings/sections/ModelsSettings.tsx` (or the Runtime sub-section) | New backend card, greyed when flag off. | +| `src/ui/src/store/*.ts` | New Zustand slice for interactive sessions. | +| `site/src/content/docs/features/settings.mdx` | Reference-table row for the flag. | +| `site/astro.config.mjs` | Sidebar entry for the new docs page. | +| `CLAUDE.md` | Document the new backend, host model, experimental flag, hook protocol. | +| `.github/copilot-instructions.md` | Mirror the additions per the alignment rule in CLAUDE.md. | + +--- + +## Phase A — Foundation: settings flag, types, migration + +### Task A1: Add `claudeInteractiveEnabled` Settings key with a failing test + +**Files:** +- Modify: `src/ui/src/store/settingsSlice.ts` (or equivalent — the file that owns the typed `Settings` shape; find via `grep -rn "pluginManagementEnabled" src/ui/src/store`) +- Modify: matching `*.test.ts` for the slice +- Create only if no existing slice test: `src/ui/src/store/settingsSlice.test.ts` + +- [ ] **Step 1: Locate the existing flag.** Run `grep -rn "pluginManagementEnabled" src/ui/src` to find the type, default, and reducer. The new flag follows the exact same shape. + +- [ ] **Step 2: Write the failing test** in the same file as the existing `pluginManagementEnabled` tests (search again to confirm path): + +```ts +it("toggles claudeInteractiveEnabled and persists", async () => { + const store = useAppStore.getState(); + expect(store.settings.claudeInteractiveEnabled).toBe(false); + await store.setSetting("claudeInteractiveEnabled", true); + expect(useAppStore.getState().settings.claudeInteractiveEnabled).toBe(true); +}); +``` + +- [ ] **Step 3: Run the test** `cd src/ui && bun run test -t "claudeInteractiveEnabled"`. Expected: FAIL with `Property 'claudeInteractiveEnabled' is missing` or `expected undefined to be false`. + +- [ ] **Step 4: Add the field to the `Settings` interface** (next to `pluginManagementEnabled`): + +```ts +export interface Settings { + // ... existing fields ... + pluginManagementEnabled: boolean; + claudeInteractiveEnabled: boolean; +} +``` + +- [ ] **Step 5: Default it to `false`** in the slice's initial state (next to `pluginManagementEnabled: false`). + +- [ ] **Step 6: Wire the setter** if the slice uses a generic key-based setter, no further work is needed. If it has explicit per-field setters, add `setClaudeInteractiveEnabled`. The existing `pluginManagementEnabled` setter is the template — match it exactly. + +- [ ] **Step 7: Rerun the test.** Run: `cd src/ui && bun run test -t "claudeInteractiveEnabled"`. Expected: PASS. + +- [ ] **Step 8: Run type check.** Run: `cd src/ui && bunx tsc -b`. Expected: exit 0, no errors. + +- [ ] **Step 9: Commit.** + +```bash +git add src/ui/src/store/settingsSlice.ts src/ui/src/store/settingsSlice.test.ts +git commit -m "feat(settings): add claudeInteractiveEnabled experimental flag" +``` + +--- + +### Task A2: Persist the flag through the Rust app_settings table + +**Files:** +- Modify: `src/db.rs` (search for `pluginManagementEnabled` to find the read path) +- Modify: `src-tauri/src/commands/settings.rs` (search for `pluginManagementEnabled` to find the IPC plumbing) + +- [ ] **Step 1: Locate the read/write paths.** Run `grep -rn "pluginManagementEnabled" src/ src-tauri/src`. Note every site. + +- [ ] **Step 2: Write a Rust test** at the bottom of `src/db.rs` (inside the existing `#[cfg(test)] mod tests` block): + +```rust +#[test] +fn claude_interactive_enabled_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + let db = Database::open(&db_path).unwrap(); + db.run_migrations().unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true").unwrap(); + let v = db.get_app_setting("claudeInteractiveEnabled").unwrap(); + assert_eq!(v.as_deref(), Some("true")); +} +``` + +- [ ] **Step 3: Run the test.** Run: `cargo test -p claudette claude_interactive_enabled_round_trips -- --exact`. Expected: PASS (this only exercises the existing generic `app_settings` table; no code change should be required). + +- [ ] **Step 4: Update the typed `AppSettings` Rust struct** if there is one (search `grep -n "pluginManagementEnabled" src/`); add the new boolean field with `#[serde(default)]` and a default `false`. + +- [ ] **Step 5: Wire the typed settings getter/setter** in `src-tauri/src/commands/settings.rs` to read/write the new key the same way `pluginManagementEnabled` is wired. + +- [ ] **Step 6: Run** `cargo test -p claudette -p claudette-server -p claudette-cli --all-features` and `cargo clippy -p claudette -p claudette-server -p claudette-cli --all-targets --all-features`. Expected: both pass, zero warnings. + +- [ ] **Step 7: Commit.** + +```bash +git add src/db.rs src-tauri/src/commands/settings.rs # plus any AppSettings file you touched +git commit -m "feat(settings): persist claudeInteractiveEnabled via app_settings" +``` + +--- + +### Task A3: Migration for `interactive_sessions` table + +**Files:** +- Create: `src/migrations/_interactive_sessions.sql` +- Modify: `src/migrations/mod.rs` + +- [ ] **Step 1: Pick the timestamp.** Run `date -u +%Y%m%d%H%M%S` and record the output as ``. Use the exact same value in the filename and the `MIGRATIONS` entry. + +- [ ] **Step 2: Write a failing test** appended to `src/migrations/mod.rs`'s `#[cfg(test)] mod tests` block: + +```rust +#[test] +fn interactive_sessions_table_is_created() { + use rusqlite::Connection; + let tmp = tempfile::NamedTempFile::new().unwrap(); + let mut conn = Connection::open(tmp.path()).unwrap(); + super::run_pending_migrations(&mut conn).unwrap(); + let cols: Vec = conn + .prepare("SELECT name FROM pragma_table_info('interactive_sessions')") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .filter_map(Result::ok) + .collect(); + for expected in [ + "sid", + "workspace_id", + "host_kind", + "state", + "crash_reason", + "created_at", + "last_attached_at", + "last_screen_blob", + "claude_flags_json", + "pid", + ] { + assert!(cols.iter().any(|c| c == expected), "missing column {expected}"); + } +} +``` + +- [ ] **Step 3: Run the test.** Run: `cargo test -p claudette interactive_sessions_table_is_created -- --exact`. Expected: FAIL with `no such table: interactive_sessions`. + +- [ ] **Step 4: Create the SQL file** at `src/migrations/_interactive_sessions.sql`: + +```sql +CREATE TABLE IF NOT EXISTS interactive_sessions ( + sid TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + host_kind TEXT NOT NULL, + state TEXT NOT NULL, + crash_reason TEXT, + created_at TEXT NOT NULL, + last_attached_at TEXT, + last_screen_blob BLOB, + claude_flags_json TEXT NOT NULL, + pid INTEGER, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_interactive_sessions_workspace + ON interactive_sessions(workspace_id); +``` + +- [ ] **Step 5: Register it.** Append the entry inside `MIGRATIONS` (substitute the real timestamp): + +```rust + Migration { + id: "_interactive_sessions", + sql: include_str!("_interactive_sessions.sql"), + legacy_version: None, + }, +``` + +- [ ] **Step 6: Run the test again.** Run: `cargo test -p claudette interactive_sessions_table_is_created -- --exact`. Expected: PASS. + +- [ ] **Step 7: Run the migration-id uniqueness test.** Run: `cargo test -p claudette migrations -- --exact`. Expected: PASS (catches you accidentally duplicating an existing ID). + +- [ ] **Step 8: Commit.** + +```bash +git add src/migrations/_interactive_sessions.sql src/migrations/mod.rs +git commit -m "feat(db): add interactive_sessions table migration" +``` + +--- + +### Task A4: DB CRUD helpers for `interactive_sessions` + +**Files:** +- Modify: `src/db.rs` + +- [ ] **Step 1: Write failing tests** in `src/db.rs`'s test module: + +```rust +#[test] +fn interactive_session_create_get_update_delete() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("t.db")).unwrap(); + db.run_migrations().unwrap(); + // Need a workspace row to satisfy the FK. + db.insert_workspace_for_test("ws-1").unwrap(); // see step 2 + + let row = InteractiveSessionRow { + sid: "claudette-ws1-aaaaaaaa".into(), + workspace_id: "ws-1".into(), + host_kind: "sidecar".into(), + state: "running".into(), + crash_reason: None, + created_at: "2026-05-16T00:00:00Z".into(), + last_attached_at: None, + last_screen_blob: None, + claude_flags_json: "[]".into(), + pid: Some(1234), + }; + db.create_interactive_session(&row).unwrap(); + let got = db.get_interactive_session("claudette-ws1-aaaaaaaa").unwrap().unwrap(); + assert_eq!(got.state, "running"); + assert_eq!(got.pid, Some(1234)); + + db.set_interactive_session_state("claudette-ws1-aaaaaaaa", "detached", None).unwrap(); + let got2 = db.get_interactive_session("claudette-ws1-aaaaaaaa").unwrap().unwrap(); + assert_eq!(got2.state, "detached"); + + db.update_interactive_session_screen("claudette-ws1-aaaaaaaa", b"\x1b[31mhi\x1b[0m").unwrap(); + let got3 = db.get_interactive_session("claudette-ws1-aaaaaaaa").unwrap().unwrap(); + assert_eq!(got3.last_screen_blob.as_deref(), Some(b"\x1b[31mhi\x1b[0m".as_slice())); + + let listed = db.list_interactive_sessions_for_workspace("ws-1").unwrap(); + assert_eq!(listed.len(), 1); + + db.delete_interactive_session("claudette-ws1-aaaaaaaa").unwrap(); + assert!(db.get_interactive_session("claudette-ws1-aaaaaaaa").unwrap().is_none()); +} +``` + +- [ ] **Step 2: If a `insert_workspace_for_test` helper does not exist**, locate the existing workspace insert path (`grep -n "fn create_workspace" src/db.rs`) and add a small test-only wrapper that inserts a minimal row, gated `#[cfg(test)] pub fn insert_workspace_for_test`. + +- [ ] **Step 3: Run the test to confirm failure.** Run: `cargo test -p claudette interactive_session_create_get_update_delete -- --exact`. Expected: FAIL (no `InteractiveSessionRow`, no methods). + +- [ ] **Step 4: Add the struct and methods to `src/db.rs`:** + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct InteractiveSessionRow { + pub sid: String, + pub workspace_id: String, + pub host_kind: String, + pub state: String, + pub crash_reason: Option, + pub created_at: String, + pub last_attached_at: Option, + pub last_screen_blob: Option>, + pub claude_flags_json: String, + pub pid: Option, +} + +impl Database { + pub fn create_interactive_session(&self, row: &InteractiveSessionRow) -> rusqlite::Result<()> { + self.conn.execute( + "INSERT INTO interactive_sessions + (sid, workspace_id, host_kind, state, crash_reason, created_at, + last_attached_at, last_screen_blob, claude_flags_json, pid) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + row.sid, row.workspace_id, row.host_kind, row.state, row.crash_reason, + row.created_at, row.last_attached_at, row.last_screen_blob, + row.claude_flags_json, row.pid, + ], + )?; + Ok(()) + } + + pub fn get_interactive_session(&self, sid: &str) -> rusqlite::Result> { + let mut stmt = self.conn.prepare( + "SELECT sid, workspace_id, host_kind, state, crash_reason, created_at, + last_attached_at, last_screen_blob, claude_flags_json, pid + FROM interactive_sessions WHERE sid = ?1", + )?; + let mut rows = stmt.query([sid])?; + if let Some(r) = rows.next()? { + Ok(Some(InteractiveSessionRow { + sid: r.get(0)?, + workspace_id: r.get(1)?, + host_kind: r.get(2)?, + state: r.get(3)?, + crash_reason: r.get(4)?, + created_at: r.get(5)?, + last_attached_at: r.get(6)?, + last_screen_blob: r.get(7)?, + claude_flags_json: r.get(8)?, + pid: r.get(9)?, + })) + } else { + Ok(None) + } + } + + pub fn list_interactive_sessions_for_workspace(&self, workspace_id: &str) -> rusqlite::Result> { + let mut stmt = self.conn.prepare( + "SELECT sid, workspace_id, host_kind, state, crash_reason, created_at, + last_attached_at, last_screen_blob, claude_flags_json, pid + FROM interactive_sessions WHERE workspace_id = ?1 + ORDER BY created_at DESC", + )?; + let iter = stmt.query_map([workspace_id], |r| Ok(InteractiveSessionRow { + sid: r.get(0)?, + workspace_id: r.get(1)?, + host_kind: r.get(2)?, + state: r.get(3)?, + crash_reason: r.get(4)?, + created_at: r.get(5)?, + last_attached_at: r.get(6)?, + last_screen_blob: r.get(7)?, + claude_flags_json: r.get(8)?, + pid: r.get(9)?, + }))?; + iter.collect() + } + + pub fn set_interactive_session_state( + &self, + sid: &str, + state: &str, + crash_reason: Option<&str>, + ) -> rusqlite::Result<()> { + self.conn.execute( + "UPDATE interactive_sessions SET state = ?1, crash_reason = ?2 WHERE sid = ?3", + rusqlite::params![state, crash_reason, sid], + )?; + Ok(()) + } + + pub fn update_interactive_session_screen(&self, sid: &str, blob: &[u8]) -> rusqlite::Result<()> { + self.conn.execute( + "UPDATE interactive_sessions SET last_screen_blob = ?1, + last_attached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE sid = ?2", + rusqlite::params![blob, sid], + )?; + Ok(()) + } + + pub fn delete_interactive_session(&self, sid: &str) -> rusqlite::Result<()> { + self.conn.execute( + "DELETE FROM interactive_sessions WHERE sid = ?1", + rusqlite::params![sid], + )?; + Ok(()) + } +} +``` + +- [ ] **Step 5: Run the test.** Run: `cargo test -p claudette interactive_session_create_get_update_delete -- --exact`. Expected: PASS. + +- [ ] **Step 6: Clippy.** Run: `cargo clippy -p claudette --all-targets --all-features`. Expected: zero warnings. + +- [ ] **Step 7: Commit.** + +```bash +git add src/db.rs +git commit -m "feat(db): add interactive_sessions CRUD helpers" +``` + +--- + +## Phase B — Trait + protocol types + stub TUI + +### Task B1: Shared protocol/types module + +**Files:** +- Create: `src/agent/interactive_protocol.rs` +- Modify: `src/agent/mod.rs` + +- [ ] **Step 1: Add `pub mod interactive_protocol;`** to `src/agent/mod.rs`. + +- [ ] **Step 2: Write failing tests** at the bottom of `src/agent/interactive_protocol.rs` (create the file with this content): + +```rust +//! Wire-protocol types for the `claudette-session-host` sidecar. +//! +//! These types are also re-used by the in-process `SidecarHost` client and the +//! `claudette-session-host` binary, so they live in the library crate so both +//! sides see the same definitions. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Request { + Hello { protocol_version: u32, claudette_version: String }, + EnsureSession { sid: String, spec: SessionSpec }, + Attach { sid: String }, + SendInput { sid: String, payload: InputPayload }, + CaptureScreen { sid: String }, + Resize { sid: String, rows: u16, cols: u16 }, + Detach { sid: String, attach_id: u64 }, + Stop { sid: String, mode: StopMode }, + Status, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Response { + HelloAck { protocol_version: u32, host_version: String, pid: u32 }, + HelloNack { reason: String, supported_versions: Vec }, + SessionStarted { sid: String, pid: u32, rows: u16, cols: u16 }, + AttachStarted { attach_id: u64 }, + Ok, + ScreenSnapshot { rows: u16, cols: u16, ansi_bytes_b64: String }, + Stopped { exit_status: i32 }, + Status { sessions: Vec, host_version: String }, + Error { message: String, recoverable: bool }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Event { + Output { sid: String, bytes_b64: String, seq: u64 }, + Hook { sid: String, hook: HookFired }, + Exit { sid: String, exit_status: i32, reason: String }, + StreamError { sid: String, message: String, recoverable: bool }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionSpec { + pub working_dir: String, + pub rows: u16, + pub cols: u16, + pub claude_binary: String, + pub claude_args: Vec, + pub env: Vec<(String, String)>, + pub claude_config_dir: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionSummary { + pub sid: String, + pub pid: Option, + pub running: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum InputPayload { + Text { text: String }, + Keys { name: String }, + Bytes { bytes_b64: String }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StopMode { + Graceful, + Force, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HookFired { + Stop, + Awaiting { reason: Option }, + PromptSubmitted, + SubagentStop, + Unknown { raw_kind: String, raw_payload: String }, +} + +pub const PROTOCOL_VERSION: u32 = 1; + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(value: &T) + where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, + { + let json = serde_json::to_string(value).unwrap(); + let back: T = serde_json::from_str(&json).unwrap(); + assert_eq!(value, &back); + } + + #[test] + fn request_kinds_round_trip() { + roundtrip(&Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: "0.0.0".into(), + }); + roundtrip(&Request::EnsureSession { + sid: "x".into(), + spec: SessionSpec { + working_dir: "/tmp".into(), + rows: 24, + cols: 80, + claude_binary: "/bin/claude".into(), + claude_args: vec!["--model".into(), "opus".into()], + env: vec![("FOO".into(), "BAR".into())], + claude_config_dir: "/tmp/cfg".into(), + }, + }); + roundtrip(&Request::SendInput { + sid: "x".into(), + payload: InputPayload::Text { text: "hello\r".into() }, + }); + roundtrip(&Request::Stop { sid: "x".into(), mode: StopMode::Graceful }); + } + + #[test] + fn event_kinds_round_trip() { + roundtrip(&Event::Output { + sid: "x".into(), + bytes_b64: "aGk=".into(), + seq: 5, + }); + roundtrip(&Event::Hook { + sid: "x".into(), + hook: HookFired::Awaiting { reason: Some("blocked on permission".into()) }, + }); + } + + #[test] + fn hook_unknown_preserves_raw_for_schema_drift() { + let v = HookFired::Unknown { + raw_kind: "FutureHook".into(), + raw_payload: "{\"a\":1}".into(), + }; + roundtrip(&v); + } +} +``` + +- [ ] **Step 3: Run.** `cargo test -p claudette interactive_protocol`. Expected: PASS for all three. Then `cargo clippy -p claudette --all-targets --all-features`. Expected: zero warnings. + +- [ ] **Step 4: Commit.** + +```bash +git add src/agent/interactive_protocol.rs src/agent/mod.rs +git commit -m "feat(agent): add interactive_protocol wire types" +``` + +--- + +### Task B2: Length-prefixed JSON-line framing + +**Files:** +- Create: `src/agent/interactive_host/mod.rs` with a `frame` submodule, OR add the framing inside `interactive_protocol.rs`. We will put it in `interactive_protocol::frame` to keep client and server symmetric. + +- [ ] **Step 1: Append failing test** to `src/agent/interactive_protocol.rs`: + +```rust +#[cfg(test)] +mod frame_tests { + use super::frame::{read_frame, write_frame}; + use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex}; + + #[tokio::test] + async fn frame_round_trip() { + let (mut a, mut b) = duplex(64 * 1024); + write_frame(&mut a, b"{\"hi\":1}").await.unwrap(); + a.shutdown().await.unwrap(); + let buf = read_frame(&mut b).await.unwrap(); + assert_eq!(buf, b"{\"hi\":1}"); + } + + #[tokio::test] + async fn frame_rejects_oversized() { + let (mut a, mut b) = duplex(64 * 1024); + // 100 MB header — must reject without allocating. + let header = (100u32 * 1024 * 1024).to_be_bytes(); + a.write_all(&header).await.unwrap(); + let err = read_frame(&mut b).await.unwrap_err(); + assert!(err.to_string().contains("frame too large"), "got: {err}"); + } +} +``` + +- [ ] **Step 2: Run.** `cargo test -p claudette frame_round_trip`. Expected: FAIL (no `frame` module). + +- [ ] **Step 3: Implement the frame submodule.** Append to `src/agent/interactive_protocol.rs`: + +```rust +pub mod frame { + use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + + pub const MAX_FRAME: usize = 8 * 1024 * 1024; // 8 MB ceiling. + + pub async fn write_frame(w: &mut W, payload: &[u8]) -> std::io::Result<()> { + let len = u32::try_from(payload.len()) + .map_err(|_| std::io::Error::other("frame too large"))?; + w.write_all(&len.to_be_bytes()).await?; + w.write_all(payload).await?; + Ok(()) + } + + pub async fn read_frame(r: &mut R) -> std::io::Result> { + let mut hdr = [0u8; 4]; + r.read_exact(&mut hdr).await?; + let len = u32::from_be_bytes(hdr) as usize; + if len > MAX_FRAME { + return Err(std::io::Error::other("frame too large")); + } + let mut buf = vec![0u8; len]; + r.read_exact(&mut buf).await?; + Ok(buf) + } +} +``` + +- [ ] **Step 4: Run.** `cargo test -p claudette frame`. Expected: PASS. Then clippy on the package. Expected: zero warnings. + +- [ ] **Step 5: Commit.** + +```bash +git add src/agent/interactive_protocol.rs +git commit -m "feat(agent): add length-prefixed JSON-line framing" +``` + +--- + +### Task B3: Stub-TUI binary used by integration tests + +**Files:** +- Create: `tests/fixtures/stub-tui/Cargo.toml` +- Create: `tests/fixtures/stub-tui/src/main.rs` +- Modify: root `Cargo.toml` (`workspace.members += ["tests/fixtures/stub-tui"]`) + +- [ ] **Step 1: Create the manifest.** + +```toml +[package] +name = "stub-tui" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +``` + +- [ ] **Step 2: Create `src/main.rs`** — a deterministic line-echo TUI with three behaviors: + +```rust +//! Stub TUI used by interactive_host integration tests. +//! +//! Behavior: +//! - Prints `READY\n` on startup so tests can synchronize. +//! - Reads stdin line-by-line. Each line is echoed back as `OUT: \n`. +//! - If `STUB_TUI_FAKE_AWAITING_AFTER` is set to a positive integer N, after +//! echoing N lines we exit 0 (simulating `Stop` hook) without further output. +//! - If `STUB_TUI_CRASH_AFTER` is set, panic after that many lines. +//! - Line `quit\n` exits 0 immediately. + +use std::io::{BufRead, Write}; + +fn main() { + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "READY").unwrap(); + stdout.flush().unwrap(); + + let limit: Option = std::env::var("STUB_TUI_FAKE_AWAITING_AFTER") + .ok() + .and_then(|s| s.parse().ok()); + let crash_after: Option = std::env::var("STUB_TUI_CRASH_AFTER") + .ok() + .and_then(|s| s.parse().ok()); + + let stdin = std::io::stdin(); + let mut count: u32 = 0; + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + if line == "quit" { + return; + } + writeln!(stdout, "OUT: {line}").unwrap(); + stdout.flush().unwrap(); + count += 1; + if let Some(n) = limit { + if count >= n { + return; + } + } + if let Some(n) = crash_after { + if count >= n { + panic!("stub-tui crashing as instructed"); + } + } + } +} +``` + +- [ ] **Step 3: Register the member** in the workspace `Cargo.toml`. Find the `[workspace]` `members = [ ... ]` array and append `"tests/fixtures/stub-tui"`. + +- [ ] **Step 4: Build.** Run: `cargo build -p stub-tui`. Expected: succeeds. + +- [ ] **Step 5: Smoke-run the binary.** Run: `echo -e "hi\nbye\nquit" | cargo run -q -p stub-tui`. Expected output: +``` +READY +OUT: hi +OUT: bye +``` + +- [ ] **Step 6: Commit.** + +```bash +git add tests/fixtures/stub-tui Cargo.toml +git commit -m "test(agent): add stub-tui fixture for interactive host tests" +``` + +--- + +### Task B4: Define `InteractiveHost` trait + shared types + +**Files:** +- Create: `src/agent/interactive_host/mod.rs` +- Create: `src/agent/interactive_host/types.rs` +- Modify: `src/agent/mod.rs` + +- [ ] **Step 1: Create the types file** `src/agent/interactive_host/types.rs`: + +```rust +//! Shared types used by both InteractiveHost implementations. + +use serde::{Deserialize, Serialize}; + +pub use crate::agent::interactive_protocol::{ + HookFired, InputPayload, SessionSpec, StopMode, +}; + +/// Stable identifier for an interactive session. +/// +/// Format: `claudette--`. Identical between tmux and +/// sidecar so a single string identifies the session in any host. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SessionId(pub String); + +impl SessionId { + pub fn new(workspace_short: &str, sid8: &str) -> Self { + Self(format!("claudette-{workspace_short}-{sid8}")) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Returned from `ensure_session`. +#[derive(Debug, Clone)] +pub struct HostHandle { + pub sid: SessionId, + pub pid: Option, + pub rows: u16, + pub cols: u16, +} + +/// Identifies a single attach subscription. Multiple attaches per session are +/// allowed; detach uses the attach_id to drop the right one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AttachId(pub u64); + +/// Event yielded by an `AttachStream`. +#[derive(Debug, Clone)] +pub enum AttachEvent { + Output { bytes: Vec, seq: u64 }, + Hook(HookFired), + Exit { exit_status: i32, reason: String }, + Error { message: String, recoverable: bool }, +} + +/// Snapshot of the current screen for instant repaint on reattach. +#[derive(Debug, Clone)] +pub struct ScreenSnapshot { + pub rows: u16, + pub cols: u16, + pub ansi_bytes: Vec, +} + +/// Host enumeration entry. +#[derive(Debug, Clone)] +pub struct HostSessionSummary { + pub sid: SessionId, + pub pid: Option, + pub running: bool, +} + +#[derive(Debug, Clone)] +pub struct HostStatus { + pub host_version: String, + pub sessions: Vec, +} +``` + +- [ ] **Step 2: Create the trait** at `src/agent/interactive_host/mod.rs`: + +```rust +//! Detachable host abstraction for interactive `claude` sessions. + +pub mod availability; +pub mod conformance; +pub mod sidecar; +#[cfg(unix)] +pub mod tmux; +pub mod types; + +pub use types::{ + AttachEvent, AttachId, HostHandle, HostSessionSummary, HostStatus, ScreenSnapshot, SessionId, +}; +pub use crate::agent::interactive_protocol::{HookFired, InputPayload, SessionSpec, StopMode}; + +use async_trait::async_trait; +use std::pin::Pin; +use tokio_stream::Stream; + +/// Type alias for the live attach stream. +pub type AttachStream = Pin + Send + 'static>>; + +#[async_trait] +pub trait InteractiveHost: Send + Sync { + async fn ensure_session(&self, sid: &SessionId, spec: &SessionSpec) -> Result; + async fn attach(&self, sid: &SessionId) -> Result<(AttachId, AttachStream), HostError>; + async fn send_input(&self, sid: &SessionId, payload: InputPayload) -> Result<(), HostError>; + async fn capture_screen(&self, sid: &SessionId) -> Result; + async fn resize(&self, sid: &SessionId, rows: u16, cols: u16) -> Result<(), HostError>; + async fn detach(&self, sid: &SessionId, attach_id: AttachId) -> Result<(), HostError>; + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError>; + async fn status(&self) -> Result; +} + +#[derive(Debug, thiserror::Error)] +pub enum HostError { + #[error("session not found: {0}")] + NotFound(String), + #[error("host unavailable: {0}")] + Unavailable(String), + #[error("protocol error: {0}")] + Protocol(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("other: {0}")] + Other(String), +} +``` + +- [ ] **Step 3: Add `pub mod interactive_host;`** to `src/agent/mod.rs` and re-export the trait + main types: + +```rust +pub mod interactive_host; +pub use interactive_host::{ + AttachEvent, AttachId, AttachStream, HostError, HostHandle, HostStatus, InteractiveHost, + ScreenSnapshot, SessionId, +}; +``` + +- [ ] **Step 4: Add deps to root `Cargo.toml`.** If not already present in workspace deps: `async-trait = "0.1"`, `tokio-stream = "0.1"`, `thiserror = "2"` (use the version already pinned elsewhere in the workspace — `grep -n "thiserror" Cargo.toml` first). + +- [ ] **Step 5: Build.** Run: `cargo build -p claudette`. Expected: succeeds. + +- [ ] **Step 6: Clippy.** Run: `cargo clippy -p claudette --all-targets --all-features`. Expected: zero warnings. + +- [ ] **Step 7: Commit.** + +```bash +git add src/agent/interactive_host src/agent/mod.rs Cargo.toml +git commit -m "feat(agent): define InteractiveHost trait and shared types" +``` + +--- + +### Task B5: Conformance suite skeleton + +**Files:** +- Create: `src/agent/interactive_host/conformance.rs` + +- [ ] **Step 1: Write the suite skeleton.** Each test is `async` and takes a fresh `Box` + a `SessionSpec` configured to launch the stub TUI: + +```rust +//! Conformance suite both InteractiveHost impls must pass. +//! +//! Tests build a host, point it at a stub TUI, and exercise the full lifecycle. +//! Both impls share the same expectations. + +use super::{ + AttachEvent, HostError, HostStatus, InteractiveHost, InputPayload, ScreenSnapshot, + SessionId, SessionSpec, StopMode, +}; +use crate::agent::interactive_protocol::HookFired; +use futures::StreamExt; +use std::time::Duration; +use tokio::time::timeout; + +pub struct ConformanceFixture { + pub spec: SessionSpec, + pub sid: SessionId, +} + +/// Run the full conformance suite against `host`. +pub async fn run(host: &H, fx: &ConformanceFixture) { + ensure_session_is_idempotent(host, fx).await; + send_then_capture_returns_bytes(host, fx).await; + multiple_attaches_each_receive_events(host, fx).await; + detach_does_not_kill_session(host, fx).await; + stop_graceful_yields_exit_event(host, fx).await; + status_lists_only_running_sessions(host, fx).await; +} + +async fn ensure_session_is_idempotent(host: &H, fx: &ConformanceFixture) { + let h1 = host.ensure_session(&fx.sid, &fx.spec).await.expect("ensure 1"); + let h2 = host.ensure_session(&fx.sid, &fx.spec).await.expect("ensure 2"); + assert_eq!(h1.sid, h2.sid); +} + +async fn send_then_capture_returns_bytes(host: &H, fx: &ConformanceFixture) { + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + let (_attach_id, mut stream) = host.attach(&fx.sid).await.unwrap(); + host.send_input(&fx.sid, InputPayload::Text { text: "hello\n".into() }) + .await + .unwrap(); + let mut got = Vec::::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + while tokio::time::Instant::now() < deadline { + match timeout(Duration::from_millis(200), stream.next()).await { + Ok(Some(AttachEvent::Output { bytes, .. })) => got.extend_from_slice(&bytes), + Ok(Some(_)) => {} + Ok(None) => break, + Err(_) => {} + } + if String::from_utf8_lossy(&got).contains("OUT: hello") { + break; + } + } + assert!( + String::from_utf8_lossy(&got).contains("OUT: hello"), + "did not see echoed line in stream: {:?}", + String::from_utf8_lossy(&got) + ); + let snap: ScreenSnapshot = host.capture_screen(&fx.sid).await.unwrap(); + assert!(snap.rows >= 1 && snap.cols >= 1); +} + +async fn multiple_attaches_each_receive_events(host: &H, fx: &ConformanceFixture) { + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + let (_a1, mut s1) = host.attach(&fx.sid).await.unwrap(); + let (_a2, mut s2) = host.attach(&fx.sid).await.unwrap(); + host.send_input(&fx.sid, InputPayload::Text { text: "ping\n".into() }) + .await + .unwrap(); + let s1_seen = drain_until_contains(&mut s1, "OUT: ping", Duration::from_secs(3)).await; + let s2_seen = drain_until_contains(&mut s2, "OUT: ping", Duration::from_secs(3)).await; + assert!(s1_seen, "first attach missed ping"); + assert!(s2_seen, "second attach missed ping"); +} + +async fn detach_does_not_kill_session(host: &H, fx: &ConformanceFixture) { + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + let (attach_id, _stream) = host.attach(&fx.sid).await.unwrap(); + host.detach(&fx.sid, attach_id).await.unwrap(); + // Session must still be enumerable as running. + let st = host.status().await.unwrap(); + assert!( + st.sessions.iter().any(|s| s.sid == fx.sid && s.running), + "session vanished after detach" + ); +} + +async fn stop_graceful_yields_exit_event(host: &H, fx: &ConformanceFixture) { + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + let (_attach_id, mut stream) = host.attach(&fx.sid).await.unwrap(); + host.stop(&fx.sid, StopMode::Graceful).await.unwrap(); + let mut got_exit = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + match timeout(Duration::from_millis(200), stream.next()).await { + Ok(Some(AttachEvent::Exit { .. })) => { + got_exit = true; + break; + } + Ok(Some(_)) => {} + Ok(None) => break, + Err(_) => {} + } + } + assert!(got_exit, "no exit event after stop"); +} + +async fn status_lists_only_running_sessions(host: &H, _fx: &ConformanceFixture) { + let st: HostStatus = host.status().await.unwrap(); + // After stop in the previous test, our session should be gone. + assert!(!st.sessions.iter().any(|s| s.running && s.sid.as_str().contains("claudette-"))); +} + +async fn drain_until_contains(stream: &mut S, needle: &str, total: Duration) -> bool +where + S: futures::Stream + Unpin, +{ + use futures::StreamExt; + let mut buf = Vec::::new(); + let deadline = tokio::time::Instant::now() + total; + while tokio::time::Instant::now() < deadline { + match timeout(Duration::from_millis(200), stream.next()).await { + Ok(Some(AttachEvent::Output { bytes, .. })) => buf.extend_from_slice(&bytes), + Ok(Some(_)) => {} + Ok(None) => return String::from_utf8_lossy(&buf).contains(needle), + Err(_) => {} + } + if String::from_utf8_lossy(&buf).contains(needle) { + return true; + } + } + String::from_utf8_lossy(&buf).contains(needle) +} +``` + +- [ ] **Step 2: Add dev-deps** `futures = "0.3"` to the workspace dev-deps if not already present (`grep -n "futures" Cargo.toml`). + +- [ ] **Step 3: Build.** Run: `cargo build -p claudette`. Expected: succeeds (the suite compiles even though no impl exists yet — it's parameterised). + +- [ ] **Step 4: Commit.** + +```bash +git add src/agent/interactive_host/conformance.rs Cargo.toml +git commit -m "test(agent): add InteractiveHost conformance suite skeleton" +``` + +--- + +### Task B6: Availability check (tmux + sidecar) with 30s TTL cache + +**Files:** +- Create: `src/agent/interactive_host/availability.rs` + +- [ ] **Step 1: Write failing tests** in the same file (this single test is enough — caching is exercised by it): + +```rust +//! Availability checks for the supported interactive hosts. +//! +//! These checks cache their result for 30 seconds to avoid shelling out on +//! every operation. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TmuxAvailability { + /// tmux >= 3.0 found on PATH. + Available { version: String }, + /// tmux found but too old. + TooOld { version: String, minimum: String }, + /// tmux not on PATH. + NotFound, +} + +const TTL: Duration = Duration::from_secs(30); + +struct CachedTmux { + at: Instant, + value: TmuxAvailability, +} + +static TMUX_CACHE: Mutex> = Mutex::new(None); + +pub async fn check_tmux() -> TmuxAvailability { + { + let g = TMUX_CACHE.lock().expect("poisoned"); + if let Some(c) = g.as_ref() { + if c.at.elapsed() < TTL { + return c.value.clone(); + } + } + } + let v = check_tmux_uncached().await; + let mut g = TMUX_CACHE.lock().expect("poisoned"); + *g = Some(CachedTmux { at: Instant::now(), value: v.clone() }); + v +} + +async fn check_tmux_uncached() -> TmuxAvailability { + let out = tokio::process::Command::new("tmux").arg("-V").output().await; + let Ok(out) = out else { return TmuxAvailability::NotFound }; + if !out.status.success() { + return TmuxAvailability::NotFound; + } + let s = String::from_utf8_lossy(&out.stdout); + // tmux -V prints e.g. "tmux 3.4" + let ver = s.trim().split_whitespace().nth(1).unwrap_or("").to_string(); + if version_at_least(&ver, 3, 0) { + TmuxAvailability::Available { version: ver } + } else { + TmuxAvailability::TooOld { + version: ver, + minimum: "3.0".into(), + } + } +} + +fn version_at_least(ver: &str, want_major: u32, want_minor: u32) -> bool { + let mut parts = ver.split('.'); + let major = parts.next().and_then(|p| p.parse::().ok()).unwrap_or(0); + let minor = parts + .next() + .and_then(|p| p.trim_end_matches(|c: char| !c.is_ascii_digit()).parse::().ok()) + .unwrap_or(0); + (major, minor) >= (want_major, want_minor) +} + +/// Clear the tmux cache (test-only helper). +#[cfg(test)] +pub fn clear_tmux_cache_for_test() { + *TMUX_CACHE.lock().unwrap() = None; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_at_least_examples() { + assert!(version_at_least("3.4", 3, 0)); + assert!(version_at_least("3.0", 3, 0)); + assert!(!version_at_least("2.9", 3, 0)); + assert!(!version_at_least("", 3, 0)); + assert!(version_at_least("3.4a", 3, 0)); + } + + #[tokio::test] + async fn check_tmux_cache_is_stable_within_ttl() { + clear_tmux_cache_for_test(); + let a = check_tmux().await; + let b = check_tmux().await; + assert_eq!(a, b); + } +} +``` + +- [ ] **Step 2: Run.** `cargo test -p claudette interactive_host::availability`. Expected: PASS. + +- [ ] **Step 3: Clippy.** `cargo clippy -p claudette --all-targets --all-features`. Expected: zero warnings. + +- [ ] **Step 4: Commit.** + +```bash +git add src/agent/interactive_host/availability.rs +git commit -m "feat(agent): add tmux availability check with TTL cache" +``` + +--- + +## Phase C — `claudette-session-host` sidecar crate + +### Task C1: Crate skeleton + +**Files:** +- Create: `src-session-host/Cargo.toml` +- Create: `src-session-host/src/main.rs` +- Modify: root `Cargo.toml` (add member, add `claudette-session-host` to deps section if used elsewhere) + +- [ ] **Step 1: Manifest.** + +```toml +[package] +name = "claudette-session-host" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "claudette-session-host" +path = "src/main.rs" + +[dependencies] +claudette = { path = "..", default-features = false } +tokio = { workspace = true, features = ["full"] } +interprocess = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +portable-pty = { workspace = true } +alacritty_terminal = { workspace = true } +base64 = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +futures = { workspace = true } +tokio-stream = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +``` + +If any of those workspace deps aren't present in the root `Cargo.toml`, add them (`grep -n "alacritty_terminal\|interprocess\|portable-pty\|base64" Cargo.toml`). Pick versions consistent with what `claudette` already uses. + +- [ ] **Step 2: Hello-world `main.rs`.** + +```rust +//! The Claudette interactive-Claude session host. +//! +//! Long-lived sidecar process. Owns claude PTYs. Exposes a JSON-line local +//! socket protocol (Unix-domain socket / Named Pipe). See +//! `claudette::agent::interactive_protocol`. + +fn main() -> std::io::Result<()> { + tracing_subscriber::fmt().with_env_filter("info").init(); + tracing::info!("claudette-session-host {} starting", env!("CARGO_PKG_VERSION")); + // Stub: just exit. Real server logic comes in Task C2+. + Ok(()) +} +``` + +- [ ] **Step 3: Register the member.** Add `"src-session-host"` to the workspace `members` array in the root `Cargo.toml`. + +- [ ] **Step 4: Build.** Run: `cargo build -p claudette-session-host`. Expected: succeeds. + +- [ ] **Step 5: Run it.** Run: `cargo run -q -p claudette-session-host`. Expected: prints the `INFO claudette-session-host …` line then exits 0. + +- [ ] **Step 6: Commit.** + +```bash +git add src-session-host Cargo.toml +git commit -m "feat(session-host): scaffold claudette-session-host crate" +``` + +--- + +### Task C2: Local-socket listener with handshake + +**Files:** +- Create: `src-session-host/src/server.rs` +- Modify: `src-session-host/src/main.rs` + +- [ ] **Step 1: Determine the socket path scheme.** Use this module-level helper (paste into `server.rs`): + +```rust +//! Local-socket server for the session host. +//! +//! Listens on a per-user path: +//! Unix: $TMPDIR/claudette-session-host/.sock +//! Windows: \\.\pipe\claudette-session-host- + +use interprocess::local_socket::{ + GenericFilePath, GenericNamespaced, ListenerOptions, Stream, ToFsName, ToNsName, prelude::*, + tokio::Listener, +}; +use std::path::PathBuf; + +pub fn default_socket_path() -> PathBuf { + let user = whoami::username(); + #[cfg(unix)] + { + let base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into()); + let dir = PathBuf::from(base).join("claudette-session-host"); + let _ = std::fs::create_dir_all(&dir); + dir.join(format!("{user}.sock")) + } + #[cfg(windows)] + { + PathBuf::from(format!(r"\\.\pipe\claudette-session-host-{user}")) + } +} +``` + +Add `whoami = "1"` to the session-host crate's deps (and workspace deps if not present). + +- [ ] **Step 2: Write a failing integration test.** Create `tests/handshake.rs` in the session-host crate (`src-session-host/tests/handshake.rs`): + +```rust +use claudette::agent::interactive_protocol::{frame, Request, Response, PROTOCOL_VERSION}; +use interprocess::local_socket::{ToFsName, prelude::*, tokio::Stream}; + +#[tokio::test] +async fn handshake_round_trip() { + let socket_path = std::env::temp_dir().join(format!( + "claudette-handshake-test-{}.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&socket_path); + let server = tokio::spawn({ + let sp = socket_path.clone(); + async move { claudette_session_host::server::run_for_test(&sp).await.unwrap() } + }); + // Give the listener time to bind. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let name = socket_path.as_path().to_fs_name::().unwrap(); + let mut s = Stream::connect(name).await.unwrap(); + let req = serde_json::to_vec(&Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: "test".into(), + }) + .unwrap(); + let (mut r, mut w) = s.split(); + frame::write_frame(&mut w, &req).await.unwrap(); + let resp_bytes = frame::read_frame(&mut r).await.unwrap(); + let resp: Response = serde_json::from_slice(&resp_bytes).unwrap(); + match resp { + Response::HelloAck { protocol_version, .. } => assert_eq!(protocol_version, PROTOCOL_VERSION), + other => panic!("expected HelloAck, got {other:?}"), + } + server.abort(); +} +``` + +- [ ] **Step 3: Make the crate a lib too** so the test can call into it. In `src-session-host/Cargo.toml` add: + +```toml +[lib] +name = "claudette_session_host" +path = "src/lib.rs" +``` + +Create `src-session-host/src/lib.rs`: + +```rust +pub mod server; +pub mod session; +pub mod idle; +``` + +- [ ] **Step 4: Run test.** `cargo test -p claudette-session-host --test handshake -- --nocapture`. Expected: FAIL (no `server::run_for_test`). + +- [ ] **Step 5: Implement the server.** Append to `src-session-host/src/server.rs`: + +```rust +use claudette::agent::interactive_protocol::{ + frame::{read_frame, write_frame}, + Event, Request, Response, PROTOCOL_VERSION, +}; +use std::path::Path; +use tokio::io::AsyncWriteExt; + +pub async fn run_for_test(socket_path: &Path) -> std::io::Result<()> { + run_at(socket_path).await +} + +pub async fn run_at(socket_path: &Path) -> std::io::Result<()> { + let name = socket_path.to_fs_name::()?; + let listener = ListenerOptions::new().name(name).create_tokio()?; + loop { + let stream = match listener.accept().await { + Ok(s) => s, + Err(e) => { + tracing::warn!(?e, "accept failed"); + continue; + } + }; + tokio::spawn(async move { + if let Err(e) = handle_connection(stream).await { + tracing::warn!(?e, "connection ended with error"); + } + }); + } +} + +async fn handle_connection(stream: Stream) -> std::io::Result<()> { + let (mut r, mut w) = stream.split(); + // First frame must be Hello. + let first = read_frame(&mut r).await?; + let req: Request = serde_json::from_slice(&first).map_err(std::io::Error::other)?; + let Request::Hello { protocol_version, .. } = req else { + let bad = Response::Error { + message: "first frame was not Hello".into(), + recoverable: false, + }; + write_frame(&mut w, &serde_json::to_vec(&bad).unwrap()).await?; + return Ok(()); + }; + let resp = if protocol_version == PROTOCOL_VERSION { + Response::HelloAck { + protocol_version: PROTOCOL_VERSION, + host_version: env!("CARGO_PKG_VERSION").to_string(), + pid: std::process::id(), + } + } else { + Response::HelloNack { + reason: format!("unsupported protocol_version {protocol_version}"), + supported_versions: vec![PROTOCOL_VERSION], + } + }; + write_frame(&mut w, &serde_json::to_vec(&resp).unwrap()).await?; + Ok(()) +} +``` + +- [ ] **Step 6: Update `main.rs`** to call `server::run_at(&server::default_socket_path()).await?;` and use `#[tokio::main]`. + +- [ ] **Step 7: Run the test.** `cargo test -p claudette-session-host --test handshake -- --nocapture`. Expected: PASS. + +- [ ] **Step 8: Clippy.** `cargo clippy -p claudette-session-host --all-targets --all-features`. Expected: zero warnings. + +- [ ] **Step 9: Commit.** + +```bash +git add src-session-host +git commit -m "feat(session-host): accept connections and answer Hello handshake" +``` + +--- + +### Task C3: Per-session PTY actor (ensure_session + status) + +**Files:** +- Create: `src-session-host/src/session.rs` +- Modify: `src-session-host/src/server.rs` + +- [ ] **Step 1: Write a failing integration test** at `src-session-host/tests/ensure_session.rs`: + +```rust +use claudette::agent::interactive_protocol::{frame, Request, Response, SessionSpec, PROTOCOL_VERSION}; +use interprocess::local_socket::{ToFsName, prelude::*, tokio::Stream}; +use std::path::PathBuf; + +fn stub_tui_binary() -> PathBuf { + // Set by cargo for any workspace test that has stub-tui as a build target. + PathBuf::from(env!("CARGO_BIN_EXE_stub-tui")) +} + +#[tokio::test] +async fn ensure_session_starts_and_status_lists_it() { + let socket = std::env::temp_dir().join(format!("ess-test-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + let server = tokio::spawn({ + let sp = socket.clone(); + async move { claudette_session_host::server::run_for_test(&sp).await.unwrap() } + }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let mut conn = open_conn(&socket).await; + send(&mut conn, &Request::Hello { protocol_version: PROTOCOL_VERSION, claudette_version: "t".into() }).await; + expect_helloack(&mut conn).await; + + send(&mut conn, &Request::EnsureSession { + sid: "claudette-test-aaaaaaaa".into(), + spec: SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub_tui_binary().to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }, + }).await; + let resp = recv(&mut conn).await; + match resp { + Response::SessionStarted { sid, .. } => assert_eq!(sid, "claudette-test-aaaaaaaa"), + other => panic!("expected SessionStarted, got {other:?}"), + } + + send(&mut conn, &Request::Status).await; + let st = recv(&mut conn).await; + match st { + Response::Status { sessions, .. } => { + assert!(sessions.iter().any(|s| s.sid == "claudette-test-aaaaaaaa" && s.running)); + } + other => panic!("expected Status, got {other:?}"), + } + server.abort(); +} + +// Helpers +async fn open_conn(path: &std::path::Path) -> Stream { + Stream::connect(path.to_fs_name::().unwrap()).await.unwrap() +} +async fn send(s: &mut Stream, req: &Request) { + let bytes = serde_json::to_vec(req).unwrap(); + let (_r, mut w) = s.split(); + frame::write_frame(&mut w, &bytes).await.unwrap(); +} +async fn recv(s: &mut Stream) -> Response { + let (mut r, _w) = s.split(); + let buf = frame::read_frame(&mut r).await.unwrap(); + serde_json::from_slice(&buf).unwrap() +} +async fn expect_helloack(s: &mut Stream) { + match recv(s).await { + Response::HelloAck { .. } => {} + other => panic!("expected HelloAck, got {other:?}"), + } +} +``` + +Note: `CARGO_BIN_EXE_stub-tui` requires the test to declare `stub-tui` as a dep. Add to `src-session-host/Cargo.toml`: + +```toml +[dev-dependencies] +stub-tui = { path = "../tests/fixtures/stub-tui", artifact = "bin:stub-tui" } +``` + +If the artifact-deps feature isn't available, use the alternative: spawn `stub-tui` by absolute path discovered via `cargo metadata` — but artifact deps are stable on Rust edition 2024 and the workspace pins ≥ 1.94 per `mise.toml`. + +- [ ] **Step 2: Run test.** `cargo test -p claudette-session-host --test ensure_session -- --nocapture`. Expected: FAIL (server doesn't handle `EnsureSession`). + +- [ ] **Step 3: Implement `session.rs`** — a per-session actor that owns a `PtyPair` from `portable-pty`: + +```rust +use claudette::agent::interactive_protocol::{HookFired, InputPayload, SessionSpec}; +use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize, PtySystem}; +use std::sync::Arc; +use tokio::sync::{Mutex, broadcast}; +use tracing::{info, warn}; + +#[derive(Debug, Clone)] +pub enum SessionEvent { + Output { bytes: Vec, seq: u64 }, + Hook(HookFired), + Exit { exit_status: i32, reason: String }, +} + +pub struct Session { + pub sid: String, + pub pid: Option, + pub rows: Mutex, + pub cols: Mutex, + /// Broadcast channel for live attaches. + pub tx: broadcast::Sender, + pty: Mutex>, + writer: Mutex>>, + /// Last screen replay bytes (capped). Used by capture_screen. + pub screen: Arc>>, + pub running: Arc, +} + +impl Session { + pub async fn spawn(sid: String, spec: SessionSpec) -> std::io::Result> { + let pty_system = NativePtySystem::default(); + let pair = pty_system + .openpty(PtySize { rows: spec.rows, cols: spec.cols, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| std::io::Error::other(e.to_string()))?; + let mut cmd = CommandBuilder::new(&spec.claude_binary); + for arg in &spec.claude_args { cmd.arg(arg); } + for (k, v) in &spec.env { cmd.env(k, v); } + cmd.env("CLAUDE_CONFIG_DIR", &spec.claude_config_dir); + cmd.cwd(&spec.working_dir); + let mut child = pair.slave.spawn_command(cmd) + .map_err(|e| std::io::Error::other(e.to_string()))?; + let pid = child.process_id(); + let writer = pair.master.take_writer() + .map_err(|e| std::io::Error::other(e.to_string()))?; + let mut reader = pair.master.try_clone_reader() + .map_err(|e| std::io::Error::other(e.to_string()))?; + let (tx, _) = broadcast::channel(2048); + let running = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let session = Arc::new(Self { + sid: sid.clone(), + pid, + rows: Mutex::new(spec.rows), + cols: Mutex::new(spec.cols), + tx: tx.clone(), + pty: Mutex::new(Some(pair)), + writer: Mutex::new(Some(writer)), + screen: Arc::new(Mutex::new(Vec::new())), + running: running.clone(), + }); + + // Reader task: pumps PTY output to broadcast + screen blob. + let screen = session.screen.clone(); + let tx_reader = tx.clone(); + let running_reader = running.clone(); + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8192]; + let mut seq: u64 = 0; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + seq += 1; + let bytes = buf[..n].to_vec(); + // Capture into screen, cap at 256 KB. + { + let mut s = screen.blocking_lock(); + s.extend_from_slice(&bytes); + let max = 256 * 1024; + if s.len() > max { + let drop_to = s.len() - max; + s.drain(..drop_to); + } + } + let _ = tx_reader.send(SessionEvent::Output { bytes, seq }); + } + } + } + running_reader.store(false, std::sync::atomic::Ordering::SeqCst); + }); + + // Waiter task: reaps child + emits Exit. + let tx_exit = tx.clone(); + tokio::task::spawn_blocking(move || { + match child.wait() { + Ok(status) => { + let code = status.exit_code() as i32; + let _ = tx_exit.send(SessionEvent::Exit { + exit_status: code, + reason: format!("child exited with {code}"), + }); + } + Err(e) => { + let _ = tx_exit.send(SessionEvent::Exit { + exit_status: -1, + reason: format!("wait failed: {e}"), + }); + } + } + }); + + info!(%sid, ?pid, "session spawned"); + Ok(session) + } + + pub async fn send_input(&self, payload: InputPayload) -> std::io::Result<()> { + let bytes = match payload { + InputPayload::Text { text } => text.into_bytes(), + InputPayload::Bytes { bytes_b64 } => { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .decode(bytes_b64) + .map_err(std::io::Error::other)? + } + InputPayload::Keys { name } => key_bytes(&name), + }; + let mut w = self.writer.lock().await; + let Some(writer) = w.as_mut() else { return Err(std::io::Error::other("session closed")) }; + writer.write_all(&bytes)?; + writer.flush()?; + Ok(()) + } + + pub async fn resize(&self, rows: u16, cols: u16) -> std::io::Result<()> { + let pty = self.pty.lock().await; + let Some(pair) = pty.as_ref() else { return Err(std::io::Error::other("session closed")) }; + pair.master + .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| std::io::Error::other(e.to_string()))?; + *self.rows.lock().await = rows; + *self.cols.lock().await = cols; + Ok(()) + } + + pub async fn stop_graceful(&self) { + // Send Ctrl+C first. Real wait/kill done by the server task. + let _ = self.send_input(InputPayload::Keys { name: "C-c".into() }).await; + } + + pub async fn capture_screen(&self) -> Vec { + self.screen.lock().await.clone() + } +} + +fn key_bytes(name: &str) -> Vec { + match name { + "Enter" => vec![b'\r'], + "Tab" => vec![b'\t'], + "Backspace" => vec![0x7f], + "Escape" => vec![0x1b], + "C-c" => vec![0x03], + "C-d" => vec![0x04], + // Strict matcher — unknown keys yield no bytes. Tests assert on this. + _ => Vec::new(), + } +} +``` + +- [ ] **Step 4: Extend the server** to handle `EnsureSession`, `Status`, `SendInput`, `Stop`. Replace `handle_connection` with a dispatch loop after handshake. Use a per-server `SessionMap` (paste into `server.rs` above `handle_connection`): + +```rust +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use crate::session::{Session, SessionEvent}; + +pub type SessionMap = Arc>>>; + +pub fn new_session_map() -> SessionMap { + Arc::new(Mutex::new(HashMap::new())) +} + +pub async fn run_at_with(map: SessionMap, socket_path: &Path) -> std::io::Result<()> { + let name = socket_path.to_fs_name::()?; + let listener = ListenerOptions::new().name(name).create_tokio()?; + loop { + let stream = listener.accept().await?; + let m = map.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, m).await { + tracing::warn!(?e, "connection ended with error"); + } + }); + } +} + +pub async fn run_for_test(socket_path: &Path) -> std::io::Result<()> { + run_at_with(new_session_map(), socket_path).await +} +``` + +Then update `handle_connection`: + +```rust +async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<()> { + let (mut r, mut w) = stream.split(); + // Handshake (unchanged) ... then: + loop { + let frame_bytes = match read_frame(&mut r).await { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), + Err(e) => return Err(e), + }; + let req: Request = match serde_json::from_slice(&frame_bytes) { + Ok(v) => v, + Err(e) => { + let r = Response::Error { message: format!("bad request: {e}"), recoverable: false }; + write_frame(&mut w, &serde_json::to_vec(&r).unwrap()).await?; + continue; + } + }; + let resp = dispatch(&map, req).await; + write_frame(&mut w, &serde_json::to_vec(&resp).unwrap()).await?; + } +} + +async fn dispatch(map: &SessionMap, req: Request) -> Response { + match req { + Request::Hello { .. } => Response::Error { + message: "Hello received after handshake".into(), + recoverable: true, + }, + Request::EnsureSession { sid, spec } => { + let mut m = map.lock().await; + if let Some(s) = m.get(&sid) { + return Response::SessionStarted { + sid: s.sid.clone(), + pid: s.pid.unwrap_or(0), + rows: *s.rows.lock().await, + cols: *s.cols.lock().await, + }; + } + match Session::spawn(sid.clone(), spec).await { + Ok(s) => { + m.insert(sid.clone(), s.clone()); + Response::SessionStarted { sid, pid: s.pid.unwrap_or(0), rows: *s.rows.lock().await, cols: *s.cols.lock().await } + } + Err(e) => Response::Error { message: e.to_string(), recoverable: false }, + } + } + Request::Status => { + let m = map.lock().await; + let sessions = m.values().map(|s| crate::SessionSummary { + sid: s.sid.clone(), + pid: s.pid, + running: s.running.load(std::sync::atomic::Ordering::SeqCst), + }).collect::>(); + Response::Status { + host_version: env!("CARGO_PKG_VERSION").into(), + sessions, + } + } + Request::SendInput { sid, payload } => { + let m = map.lock().await; + let Some(s) = m.get(&sid).cloned() else { + return Response::Error { message: format!("not found: {sid}"), recoverable: true }; + }; + drop(m); + match s.send_input(payload).await { + Ok(()) => Response::Ok, + Err(e) => Response::Error { message: e.to_string(), recoverable: true }, + } + } + Request::Stop { sid, mode } => { + let mut m = map.lock().await; + let Some(s) = m.remove(&sid) else { + return Response::Error { message: format!("not found: {sid}"), recoverable: true }; + }; + match mode { + claudette::agent::interactive_protocol::StopMode::Graceful => s.stop_graceful().await, + claudette::agent::interactive_protocol::StopMode::Force => {} // master drop kills below + } + // Dropping pty pair kills the child. + drop(s); + Response::Stopped { exit_status: 0 } + } + // Resize / Detach / CaptureScreen / Attach come in later tasks. + Request::Resize { .. } | Request::Detach { .. } | Request::CaptureScreen { .. } | Request::Attach { .. } => { + Response::Error { message: "not yet implemented".into(), recoverable: false } + } + } +} +``` + +Re-export `SessionSummary` in the lib so the server can use it from the shared protocol. Update `src-session-host/src/lib.rs`: + +```rust +pub mod server; +pub mod session; +pub mod idle; +pub use claudette::agent::interactive_protocol::SessionSummary; +``` + +- [ ] **Step 5: Run.** `cargo test -p claudette-session-host --test ensure_session -- --nocapture`. Expected: PASS. Also re-run `cargo test -p claudette-session-host --test handshake`. Expected: still PASS. + +- [ ] **Step 6: Clippy.** `cargo clippy -p claudette-session-host --all-targets --all-features`. Expected: zero warnings. + +- [ ] **Step 7: Commit.** + +```bash +git add src-session-host +git commit -m "feat(session-host): EnsureSession + Status + SendInput + Stop" +``` + +--- + +### Task C4: Attach streaming + Detach + CaptureScreen + Resize + +**Files:** +- Modify: `src-session-host/src/server.rs` +- Modify: `src-session-host/src/session.rs` + +- [ ] **Step 1: Write a failing test** at `src-session-host/tests/attach_stream.rs`. Duplicate the connection helpers from `ensure_session.rs` (test-file duplication is fine): + +```rust +use base64::Engine as _; +use claudette::agent::interactive_protocol::{ + frame, Event, InputPayload, Request, Response, SessionSpec, PROTOCOL_VERSION, +}; +use interprocess::local_socket::{ToFsName, prelude::*, tokio::Stream}; +use std::path::PathBuf; +use std::time::Duration; + +fn stub_tui_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_stub-tui")) +} + +#[tokio::test] +async fn attach_streams_echoed_output() { + let socket = std::env::temp_dir().join(format!("attach-test-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + let server = tokio::spawn({ + let sp = socket.clone(); + async move { claudette_session_host::server::run_for_test(&sp).await.unwrap() } + }); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Connection 1: control (ensure + send input). + let mut ctrl = open_conn(&socket).await; + handshake(&mut ctrl).await; + let sid = "claudette-attach-aaaaaaaa".to_string(); + send_req(&mut ctrl, &Request::EnsureSession { + sid: sid.clone(), + spec: SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub_tui_binary().to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }, + }).await; + expect_session_started(&mut ctrl, &sid).await; + + // Connection 2: attach. + let mut att = open_conn(&socket).await; + handshake(&mut att).await; + send_req(&mut att, &Request::Attach { sid: sid.clone() }).await; + match recv_resp(&mut att).await { + Response::AttachStarted { attach_id } => assert!(attach_id > 0), + other => panic!("expected AttachStarted, got {other:?}"), + } + + // Drain stub-tui's `READY\n` first. + drain_until_contains(&mut att, "READY", Duration::from_secs(2)).await; + + // Send input on control connection. + send_req(&mut ctrl, &Request::SendInput { + sid: sid.clone(), + payload: InputPayload::Text { text: "hello\n".into() }, + }).await; + let resp = recv_resp(&mut ctrl).await; + assert!(matches!(resp, Response::Ok), "expected Ok, got {resp:?}"); + + // Drain attach until we see the echo. + let seen = drain_until_contains(&mut att, "OUT: hello", Duration::from_secs(3)).await; + assert!(seen, "did not observe echoed line on attach stream"); + + server.abort(); +} + +async fn open_conn(p: &std::path::Path) -> Stream { + Stream::connect(p.to_fs_name::().unwrap()).await.unwrap() +} +async fn handshake(s: &mut Stream) { + send_req(s, &Request::Hello { protocol_version: PROTOCOL_VERSION, claudette_version: "t".into() }).await; + match recv_resp(s).await { + Response::HelloAck { .. } => {} + other => panic!("expected HelloAck, got {other:?}"), + } +} +async fn send_req(s: &mut Stream, r: &Request) { + let bytes = serde_json::to_vec(r).unwrap(); + let (_r, mut w) = s.split(); + frame::write_frame(&mut w, &bytes).await.unwrap(); +} +async fn recv_resp(s: &mut Stream) -> Response { + let (mut r, _w) = s.split(); + let buf = frame::read_frame(&mut r).await.unwrap(); + serde_json::from_slice(&buf).unwrap() +} +async fn expect_session_started(s: &mut Stream, expected_sid: &str) { + match recv_resp(s).await { + Response::SessionStarted { sid, .. } => assert_eq!(sid, expected_sid), + other => panic!("expected SessionStarted, got {other:?}"), + } +} +async fn drain_until_contains(s: &mut Stream, needle: &str, total: Duration) -> bool { + let (mut r, _w) = s.split(); + let mut buf = Vec::::new(); + let deadline = tokio::time::Instant::now() + total; + while tokio::time::Instant::now() < deadline { + let frame_res = tokio::time::timeout( + Duration::from_millis(200), + frame::read_frame(&mut r), + ).await; + let Ok(Ok(bytes)) = frame_res else { continue }; + let ev: Event = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(_) => continue, + }; + if let Event::Output { bytes_b64, .. } = ev { + let decoded = base64::engine::general_purpose::STANDARD.decode(bytes_b64).unwrap_or_default(); + buf.extend_from_slice(&decoded); + if String::from_utf8_lossy(&buf).contains(needle) { + return true; + } + } + } + String::from_utf8_lossy(&buf).contains(needle) +} +``` + +- [ ] **Step 2: Run.** Expected: FAIL. + +- [ ] **Step 3: Implement Attach.** In `dispatch`, add: + +```rust +Request::Attach { sid } => { + // dispatch returns Response::Error here to keep the request/response shape + // sane, but actual attach output is interleaved on the same connection. + // We treat the Attach response specially in handle_connection by + // returning a marker — implementation below. + Response::Error { message: "INTERNAL_ATTACH_MARKER".into(), recoverable: false } +} +``` + +This is a bit ugly because attach streams. Cleaner: handle Attach **inside** `handle_connection` directly (don't go through `dispatch`). Move the Attach branch: + +```rust +async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<()> { + let (mut r, mut w) = stream.split(); + // Handshake first. + let first = read_frame(&mut r).await?; + let req: Request = serde_json::from_slice(&first).map_err(std::io::Error::other)?; + let Request::Hello { protocol_version, .. } = req else { + let bad = Response::Error { message: "first frame was not Hello".into(), recoverable: false }; + write_frame(&mut w, &serde_json::to_vec(&bad).unwrap()).await?; + return Ok(()); + }; + let resp = if protocol_version == PROTOCOL_VERSION { + Response::HelloAck { + protocol_version: PROTOCOL_VERSION, + host_version: env!("CARGO_PKG_VERSION").to_string(), + pid: std::process::id(), + } + } else { + Response::HelloNack { + reason: format!("unsupported protocol_version {protocol_version}"), + supported_versions: vec![PROTOCOL_VERSION], + } + }; + write_frame(&mut w, &serde_json::to_vec(&resp).unwrap()).await?; + + let mut attach_id_counter: u64 = 0; + loop { + let frame_bytes = match read_frame(&mut r).await { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), + Err(e) => return Err(e), + }; + let req: Request = serde_json::from_slice(&frame_bytes).map_err(std::io::Error::other)?; + match req { + Request::Attach { sid } => { + let m = map.lock().await; + let Some(s) = m.get(&sid).cloned() else { + let r = Response::Error { message: format!("not found: {sid}"), recoverable: true }; + write_frame(&mut w, &serde_json::to_vec(&r).unwrap()).await?; + continue; + }; + drop(m); + attach_id_counter += 1; + let attach_id = attach_id_counter; + let ack = Response::AttachStarted { attach_id }; + write_frame(&mut w, &serde_json::to_vec(&ack).unwrap()).await?; + stream_attach(&mut w, s, sid).await?; + } + other => { + let resp = dispatch(&map, other).await; + write_frame(&mut w, &serde_json::to_vec(&resp).unwrap()).await?; + } + } + } +} + +async fn stream_attach( + w: &mut W, + sess: Arc, + sid: String, +) -> std::io::Result<()> { + use base64::Engine as _; + let mut rx = sess.tx.subscribe(); + while let Ok(ev) = rx.recv().await { + let event = match ev { + SessionEvent::Output { bytes, seq } => Event::Output { + sid: sid.clone(), + bytes_b64: base64::engine::general_purpose::STANDARD.encode(&bytes), + seq, + }, + SessionEvent::Hook(h) => Event::Hook { sid: sid.clone(), hook: h }, + SessionEvent::Exit { exit_status, reason } => { + let ev = Event::Exit { sid: sid.clone(), exit_status, reason }; + write_frame(w, &serde_json::to_vec(&ev).unwrap()).await?; + break; + } + }; + let bytes = serde_json::to_vec(&event).unwrap(); + write_frame(w, &bytes).await?; + } + Ok(()) +} +``` + +- [ ] **Step 4: Implement Detach.** Detach drops the per-connection broadcast receiver. Track receivers per attach_id in a `HashMap>` on the connection. For v1 simplicity: an explicit Detach request closes the per-connection attach by setting a per-attach `tokio::sync::Notify` that `stream_attach` selects on. Implementation: + +```rust +// In handle_connection, replace the single `stream_attach` call: +let stop_notify = Arc::new(tokio::sync::Notify::new()); +let stop_clone = stop_notify.clone(); +let detach_id = attach_id; +// Spawn streaming on this connection in-place (don't `tokio::spawn` — we need +// to keep using `w`). +stream_attach_with_cancel(&mut w, s, sid.clone(), stop_notify).await?; +``` + +`stream_attach_with_cancel` uses `tokio::select!` between `rx.recv()` and `stop.notified()`. + +In the dispatch path for `Detach { sid, attach_id }`, signal the matching Notify. Bookkeeping: + +```rust +type DetachMap = Arc>>>; +``` + +Track entries by `(sid, attach_id)`. Insert on Attach, remove on Detach. The Detach Response::Ok is returned by the SAME connection that owns the attach. + +If this gets too tangled, fall back to a simpler v1 model: client closes the connection to detach (no explicit Detach request). The trait's `detach(sid, attach_id)` can call into a per-attach kill-channel. For v1, **simpler is fine** — document that "Detach via socket close" is supported, and the Detach request is a no-op that the host accepts for symmetry. Update the spec's `Sidecar wire protocol` table footnote to match. + +- [ ] **Step 5: Implement Resize and CaptureScreen** by extending `dispatch`: + +```rust +Request::Resize { sid, rows, cols } => { + let m = map.lock().await; + let Some(s) = m.get(&sid).cloned() else { return Response::Error { message: format!("not found: {sid}"), recoverable: true } }; + drop(m); + match s.resize(rows, cols).await { + Ok(()) => Response::Ok, + Err(e) => Response::Error { message: e.to_string(), recoverable: true }, + } +} +Request::CaptureScreen { sid } => { + let m = map.lock().await; + let Some(s) = m.get(&sid).cloned() else { return Response::Error { message: format!("not found: {sid}"), recoverable: true } }; + drop(m); + let bytes = s.capture_screen().await; + use base64::Engine as _; + Response::ScreenSnapshot { + rows: *s.rows.lock().await, + cols: *s.cols.lock().await, + ansi_bytes_b64: base64::engine::general_purpose::STANDARD.encode(&bytes), + } +} +``` + +- [ ] **Step 6: Run.** All sidecar tests: `cargo test -p claudette-session-host -- --nocapture`. Expected: PASS. + +- [ ] **Step 7: Clippy.** Zero warnings. + +- [ ] **Step 8: Commit.** + +```bash +git add src-session-host +git commit -m "feat(session-host): Attach streaming, Detach (via close), Resize, CaptureScreen" +``` + +--- + +### Task C5: Idle-exit timer + +**Files:** +- Create / fill: `src-session-host/src/idle.rs` +- Modify: `src-session-host/src/server.rs`, `src-session-host/src/main.rs` + +- [ ] **Step 1: Write test.** `src-session-host/tests/idle_exit.rs`: + +```rust +#[tokio::test] +async fn idle_exit_when_no_sessions_and_no_clients() { + let map = claudette_session_host::server::new_session_map(); + let idle = claudette_session_host::idle::Idle::new(std::time::Duration::from_millis(200)); + idle.notify_client_count(0); + let started = std::time::Instant::now(); + let exit_fut = claudette_session_host::idle::wait_for_idle_exit(map.clone(), idle.clone()).await; + let _ = exit_fut; // returns when idle for the configured duration + assert!(started.elapsed() >= std::time::Duration::from_millis(180)); +} +``` + +- [ ] **Step 2: Run.** Expected: FAIL. + +- [ ] **Step 3: Implement idle module:** + +```rust +//! Idle-exit timer for the sidecar. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; +use tokio::sync::Notify; +use crate::server::SessionMap; + +#[derive(Clone)] +pub struct Idle { + pub timeout: Duration, + clients: Arc, + pub waker: Arc, +} + +impl Idle { + pub fn new(timeout: Duration) -> Self { + Self { + timeout, + clients: Arc::new(AtomicUsize::new(0)), + waker: Arc::new(Notify::new()), + } + } + pub fn client_connected(&self) { + self.clients.fetch_add(1, Ordering::SeqCst); + self.waker.notify_waiters(); + } + pub fn client_disconnected(&self) { + self.clients.fetch_sub(1, Ordering::SeqCst); + self.waker.notify_waiters(); + } + pub fn notify_client_count(&self, n: usize) { + self.clients.store(n, Ordering::SeqCst); + self.waker.notify_waiters(); + } +} + +pub async fn wait_for_idle_exit(map: SessionMap, idle: Idle) { + loop { + let clients = idle.clients.load(Ordering::SeqCst); + let session_count = map.lock().await.len(); + if clients == 0 && session_count == 0 { + tokio::select! { + _ = tokio::time::sleep(idle.timeout) => { + let clients = idle.clients.load(Ordering::SeqCst); + let session_count = map.lock().await.len(); + if clients == 0 && session_count == 0 { + return; + } + } + _ = idle.waker.notified() => { continue; } + } + } else { + idle.waker.notified().await; + } + } +} +``` + +- [ ] **Step 4: Wire it.** Update `main.rs`: + +```rust +#[tokio::main] +async fn main() -> std::io::Result<()> { + tracing_subscriber::fmt().with_env_filter("info").init(); + let map = claudette_session_host::server::new_session_map(); + let idle = claudette_session_host::idle::Idle::new(std::time::Duration::from_secs(600)); + let path = claudette_session_host::server::default_socket_path(); + + let server_fut = claudette_session_host::server::run_at_with_idle(map.clone(), &path, idle.clone()); + let idle_fut = claudette_session_host::idle::wait_for_idle_exit(map.clone(), idle.clone()); + + tokio::select! { + r = server_fut => r, + _ = idle_fut => { + tracing::info!("idle timeout reached, exiting"); + Ok(()) + } + } +} +``` + +Then add `run_at_with_idle` to `server.rs` that increments/decrements `Idle` around each connection lifetime. (The simplest path: wrap the accept loop with `idle.client_connected()` / `client_disconnected()` per connection task.) + +- [ ] **Step 5: Run.** `cargo test -p claudette-session-host -- --nocapture`. Expected: all pass. + +- [ ] **Step 6: Clippy.** Zero warnings. + +- [ ] **Step 7: Commit.** + +```bash +git add src-session-host +git commit -m "feat(session-host): idle-exit when no sessions and no clients" +``` + +--- + +### Task C6: `SidecarHost` client (Rust-side `InteractiveHost` impl) + +**Files:** +- Create / fill: `src/agent/interactive_host/sidecar.rs` + +- [ ] **Step 1: Write failing tests.** Append to `src/agent/interactive_host/sidecar.rs`: + +```rust +//! `SidecarHost` — the InteractiveHost impl that talks to `claudette-session-host`. +//! +//! Owns a Tokio task that maintains a long-lived connection, multiplexes +//! requests, and fans-out attach events. + +use super::{AttachEvent, AttachId, AttachStream, HostError, HostHandle, HostStatus, InteractiveHost, ScreenSnapshot, SessionId}; +use crate::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; +use async_trait::async_trait; +use std::path::PathBuf; +use std::sync::Arc; + +pub struct SidecarHost { + socket_path: PathBuf, + binary_path: PathBuf, +} + +impl SidecarHost { + pub fn new(socket_path: PathBuf, binary_path: PathBuf) -> Self { + Self { socket_path, binary_path } + } + + /// Ensure the sidecar is running, spawning it if not. Idempotent. + pub async fn ensure_running(&self) -> std::io::Result<()> { + // Try to connect; if it fails, spawn the binary. + // Implementation lands inside this method (concrete code in step 3). + Ok(()) + } +} + +#[async_trait] +impl InteractiveHost for SidecarHost { + async fn ensure_session(&self, sid: &SessionId, spec: &SessionSpec) -> Result { todo!() } + async fn attach(&self, sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { todo!() } + async fn send_input(&self, sid: &SessionId, payload: InputPayload) -> Result<(), HostError> { todo!() } + async fn capture_screen(&self, sid: &SessionId) -> Result { todo!() } + async fn resize(&self, sid: &SessionId, rows: u16, cols: u16) -> Result<(), HostError> { todo!() } + async fn detach(&self, sid: &SessionId, attach_id: AttachId) -> Result<(), HostError> { todo!() } + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError> { todo!() } + async fn status(&self) -> Result { todo!() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::interactive_host::conformance::{run, ConformanceFixture}; + + #[tokio::test] + #[ignore = "spawned-sidecar conformance test — run with --ignored"] + async fn sidecar_passes_conformance() { + let bin = PathBuf::from(env!("CARGO_BIN_EXE_claudette-session-host")); + let stub = PathBuf::from(env!("CARGO_BIN_EXE_stub-tui")); + let socket = std::env::temp_dir().join(format!("sidecar-conformance-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + let host = SidecarHost::new(socket.clone(), bin); + host.ensure_running().await.unwrap(); + let fx = ConformanceFixture { + sid: SessionId("claudette-conformance-aaaaaaaa".into()), + spec: SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub.to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }, + }; + run(&host, &fx).await; + } +} +``` + +Add the `claudette-session-host` artifact-dep so the `CARGO_BIN_EXE_*` env-var is available. In root `Cargo.toml`'s `claudette` dev-dependencies (or `dev-dependencies` in the lib package section): + +```toml +[dev-dependencies] +claudette-session-host = { path = "src-session-host", artifact = "bin:claudette-session-host" } +stub-tui = { path = "tests/fixtures/stub-tui", artifact = "bin:stub-tui" } +``` + +- [ ] **Step 2: Run test.** `cargo test -p claudette sidecar_passes_conformance --ignored`. Expected: panic (all impls `todo!()`). + +- [ ] **Step 3: Implement the actor.** Inside `sidecar.rs`, design: + +- A `ConnHandle` struct wraps the local-socket connection. It runs one Tokio task that reads frames and dispatches them: response frames go to a per-request oneshot; attach event frames go to per-attach `mpsc::Sender`. +- `SidecarHost::ensure_running` first tries to connect to `socket_path`; if `ConnectionRefused` or `NotFound`, spawns `binary_path` as a detached child (`tokio::process::Command` with `kill_on_drop(false)`). +- Each trait method serialises the request, awaits the response oneshot. +- `attach` returns the `mpsc::Receiver` wrapped as a `Stream` via `tokio_stream::wrappers::ReceiverStream`. + +Full implementation outline (every block paste-able as concrete code; you do not need to invent additional types): + +```rust +use crate::agent::interactive_protocol::{ + frame::{read_frame, write_frame}, + Event, Request, Response, PROTOCOL_VERSION, +}; +use interprocess::local_socket::{ + GenericFilePath, ToFsName, prelude::*, tokio::Stream as SockStream, +}; +use std::collections::HashMap; +use tokio::sync::{Mutex, mpsc, oneshot}; + +struct ConnHandle { + tx: mpsc::Sender, + inflight: Arc>>>, + attaches: Arc>>>, + next_id: Arc, +} + +enum OutFrame { Bytes(Vec) } + +impl ConnHandle { + async fn connect(socket_path: &Path) -> std::io::Result { + use std::path::Path as StdPath; + let name = ::to_fs_name::(socket_path)?; + let stream = SockStream::connect(name).await?; + let (mut r, mut w) = stream.split(); + + // Send Hello. + let hello = Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: env!("CARGO_PKG_VERSION").to_string(), + }; + let env = crate::agent::interactive_protocol::RequestEnvelope { request_id: 0, request: hello }; + write_frame(&mut w, &serde_json::to_vec(&env).unwrap()).await?; + let first = read_frame(&mut r).await?; + let inbound: crate::agent::interactive_protocol::InboundFrame = + serde_json::from_slice(&first).map_err(std::io::Error::other)?; + match inbound { + crate::agent::interactive_protocol::InboundFrame::Response { response: Response::HelloAck { .. }, .. } => {} + other => return Err(std::io::Error::other(format!("handshake failed: {other:?}"))), + } + + let (tx_out, mut rx_out) = mpsc::channel::(256); + let inflight: Arc>>> = Arc::new(Mutex::new(HashMap::new())); + let attaches: Arc>>> = Arc::new(Mutex::new(HashMap::new())); + let next_id = Arc::new(std::sync::atomic::AtomicU64::new(1)); + + // Writer task: drains tx_out and writes frames. + tokio::spawn(async move { + while let Some(OutFrame::Bytes(bytes)) = rx_out.recv().await { + if write_frame(&mut w, &bytes).await.is_err() { break; } + } + }); + + // Reader task: dispatches frames to inflight or attaches. + let inflight_r = inflight.clone(); + let attaches_r = attaches.clone(); + tokio::spawn(async move { + loop { + let bytes = match read_frame(&mut r).await { + Ok(b) => b, + Err(_) => break, + }; + let Ok(frame) = serde_json::from_slice::(&bytes) else { continue }; + match frame { + crate::agent::interactive_protocol::InboundFrame::Response { request_id, response } => { + if let Some(tx) = inflight_r.lock().await.remove(&request_id) { + let _ = tx.send(response); + } + } + crate::agent::interactive_protocol::InboundFrame::Event(ev) => { + let (sid, ev) = match ev { + Event::Output { sid, bytes_b64, seq } => { + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD.decode(bytes_b64).unwrap_or_default(); + (sid, AttachEvent::Output { bytes, seq }) + } + Event::Hook { sid, hook } => (sid, AttachEvent::Hook(hook)), + Event::Exit { sid, exit_status, reason } => (sid, AttachEvent::Exit { exit_status, reason }), + Event::StreamError { sid, message, recoverable } => (sid, AttachEvent::Error { message, recoverable }), + }; + if let Some(tx) = attaches_r.lock().await.get(&sid).cloned() { + let _ = tx.send(ev).await; + } + } + } + } + }); + + Ok(Self { tx: tx_out, inflight, attaches, next_id }) + } + + async fn request(&self, req: Request) -> Result { + let request_id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let (tx_resp, rx_resp) = oneshot::channel(); + self.inflight.lock().await.insert(request_id, tx_resp); + let env = crate::agent::interactive_protocol::RequestEnvelope { request_id, request: req }; + let bytes = serde_json::to_vec(&env).map_err(|e| HostError::Other(e.to_string()))?; + self.tx.send(OutFrame::Bytes(bytes)).await.map_err(|_| HostError::Other("conn closed".into()))?; + rx_resp.await.map_err(|_| HostError::Other("response channel dropped".into())) + } + + async fn attach_for(&self, sid: String) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(1024); + self.attaches.lock().await.insert(sid, tx); + rx + } +} +``` + +The pump: + +1. Connect to `socket_path` (return `io::Error::NotFound` if it doesn't exist — caller will spawn the binary). +2. Send `Request::Hello`. +3. Read response — error if not `HelloAck`. +4. Split the stream into reader/writer halves. +5. Spawn read task: each inbound frame is parsed as either `Response` (delivered to an inflight oneshot by request-id correlation) or `Event` (delivered to per-`sid` attach channel). +6. Writer task pulls from the outbound `mpsc::Receiver` and writes frames. + +For request-id correlation: each `Request` is wrapped in `serde_json::json!({ "request_id": , "request": })`. Add a small request-envelope at the protocol layer. **Update `interactive_protocol.rs` to define:** + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestEnvelope { + pub request_id: u64, + pub request: Request, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum InboundFrame { + Response { request_id: u64, response: Response }, + Event(Event), +} +``` + +And update the **session host's `handle_connection`** to read `RequestEnvelope` and reply with the corresponding shape. Round-trip test (add to `interactive_protocol.rs`): + +```rust +#[test] +fn request_envelope_round_trips() { + let env = RequestEnvelope { request_id: 42, request: Request::Status }; + let s = serde_json::to_string(&env).unwrap(); + let back: RequestEnvelope = serde_json::from_str(&s).unwrap(); + assert_eq!(back.request_id, 42); +} +``` + +This wire-format change is **breaking** for protocol-version 1. That is fine — nothing ships yet. Keep `PROTOCOL_VERSION = 1`. + +- [ ] **Step 4: Update the session-host server to use envelopes.** In `src-session-host/src/server.rs`, change the read loop to deserialize `RequestEnvelope` and write responses as the matching `InboundFrame::Response { request_id, response }`: + +```rust +// Replace the existing per-frame parse: +let env: crate::agent::interactive_protocol::RequestEnvelope = + serde_json::from_slice(&frame_bytes).map_err(std::io::Error::other)?; +let req = env.request; +let request_id = env.request_id; +// ...existing dispatch returns Response... +let outbound = crate::agent::interactive_protocol::InboundFrame::Response { request_id, response: resp }; +write_frame(&mut w, &serde_json::to_vec(&outbound).unwrap()).await?; +``` + +Update Hello-handshake handling so the first frame is an envelope too (`request_id == 0` by convention). Update the existing handshake/ensure_session/attach_stream tests in `src-session-host/tests/` to send envelopes and expect `InboundFrame::Response` shapes — the test helpers should grow `send_env` and `recv_inbound` variants. Run the entire `src-session-host` test suite and confirm green. + +- [ ] **Step 5: Run.** `cargo test -p claudette sidecar_passes_conformance --ignored` and `cargo test -p claudette-session-host`. Expected: both pass. + +- [ ] **Step 6: Clippy.** Zero warnings in both crates. + +- [ ] **Step 7: Commit.** + +```bash +git add src/agent/interactive_host/sidecar.rs src/agent/interactive_protocol.rs src-session-host Cargo.toml +git commit -m "feat(agent): SidecarHost client passes InteractiveHost conformance" +``` + +--- + +## Phase D — Tmux host + +### Task D1: `TmuxHost` implementation + +**Files:** +- Create: `src/agent/interactive_host/tmux.rs` (`#[cfg(unix)]`) + +This is one task because every operation is a shell-out; the implementations are small. + +- [ ] **Step 1: Write a failing ignored test** at the bottom of the file: + +```rust +//! TmuxHost — InteractiveHost backed by the system tmux binary. + +#![cfg(unix)] + +use super::{ + AttachEvent, AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, +}; +use super::availability::{check_tmux, TmuxAvailability}; +use crate::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; +use async_trait::async_trait; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::process::Command; + +pub struct TmuxHost { + /// Per-host directory under $TMPDIR for FIFOs etc. + runtime_dir: PathBuf, + next_attach: Arc, +} + +impl TmuxHost { + pub fn new(runtime_dir: PathBuf) -> Self { + Self { runtime_dir, next_attach: Arc::new(AtomicU64::new(0)) } + } +} + +#[async_trait] +impl InteractiveHost for TmuxHost { + // ... methods below ... +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::interactive_host::conformance::{run, ConformanceFixture}; + + #[tokio::test] + #[ignore = "requires tmux >= 3.0"] + async fn tmux_passes_conformance() { + match check_tmux().await { + TmuxAvailability::Available { .. } => {} + other => { + eprintln!("skipping: tmux not available: {other:?}"); + return; + } + } + let dir = tempfile::tempdir().unwrap(); + let host = TmuxHost::new(dir.path().to_path_buf()); + let stub = PathBuf::from(env!("CARGO_BIN_EXE_stub-tui")); + let fx = ConformanceFixture { + sid: SessionId("claudette-tmux-conformance-aaaaaaaa".into()), + spec: SessionSpec { + working_dir: dir.path().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub.to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: dir.path().to_string_lossy().into(), + }, + }; + run(&host, &fx).await; + } +} +``` + +- [ ] **Step 2: Run.** `cargo test -p claudette tmux_passes_conformance --ignored`. Expected: not compiled or failing because methods are missing. + +- [ ] **Step 3: Implement each method.** Concrete: + +```rust +impl TmuxHost { + fn fifo_path(&self, sid: &SessionId) -> PathBuf { + self.runtime_dir.join(format!("{}.fifo", sid.as_str())) + } +} + +#[async_trait] +impl InteractiveHost for TmuxHost { + async fn ensure_session(&self, sid: &SessionId, spec: &SessionSpec) -> Result { + let exists = Command::new("tmux") + .args(["has-session", "-t", sid.as_str()]) + .output().await + .map(|o| o.status.success()) + .unwrap_or(false); + if !exists { + let mut cmd = Command::new("tmux"); + cmd.args(["new-session", "-d", "-s", sid.as_str(), + "-x", &spec.cols.to_string(), + "-y", &spec.rows.to_string()]); + for (k, v) in &spec.env { + cmd.args(["-e", &format!("{k}={v}")]); + } + cmd.args(["-e", &format!("CLAUDE_CONFIG_DIR={}", spec.claude_config_dir)]); + cmd.arg("--").arg(&spec.claude_binary); + for arg in &spec.claude_args { cmd.arg(arg); } + let st = cmd.status().await.map_err(HostError::Io)?; + if !st.success() { + return Err(HostError::Other(format!("tmux new-session failed: {st}"))); + } + // Set up pipe-pane to the fifo (idempotent). + std::fs::create_dir_all(&self.runtime_dir).ok(); + let fifo = self.fifo_path(sid); + if !fifo.exists() { + nix::unistd::mkfifo(&fifo, nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR) + .map_err(|e| HostError::Other(e.to_string()))?; + } + let pipe_cmd = format!("cat >> {}", shell_escape(&fifo.to_string_lossy())); + Command::new("tmux").args(["pipe-pane", "-O", "-t", sid.as_str(), &pipe_cmd]) + .status().await.map_err(HostError::Io)?; + } + Ok(HostHandle { sid: sid.clone(), pid: None, rows: spec.rows, cols: spec.cols }) + } + + async fn attach(&self, sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + let id = AttachId(self.next_attach.fetch_add(1, Ordering::SeqCst)); + let fifo = self.fifo_path(sid); + let (tx, rx) = tokio::sync::mpsc::channel::(1024); + // Open the FIFO for reading on a blocking thread; pump bytes to tx as Output events. + let fifo_clone = fifo.clone(); + tokio::task::spawn_blocking(move || { + let mut f = match std::fs::OpenOptions::new().read(true).open(&fifo_clone) { + Ok(f) => f, + Err(e) => { let _ = tx.blocking_send(AttachEvent::Error { message: e.to_string(), recoverable: false }); return; } + }; + let mut buf = [0u8; 8192]; + let mut seq: u64 = 0; + use std::io::Read; + loop { + match f.read(&mut buf) { + Ok(0) => { std::thread::sleep(std::time::Duration::from_millis(50)); } + Ok(n) => { seq += 1; if tx.blocking_send(AttachEvent::Output { bytes: buf[..n].to_vec(), seq }).is_err() { return; } } + Err(_) => return, + } + } + }); + use tokio_stream::wrappers::ReceiverStream; + let stream: AttachStream = Box::pin(ReceiverStream::new(rx)); + Ok((id, stream)) + } + + async fn send_input(&self, sid: &SessionId, payload: InputPayload) -> Result<(), HostError> { + let mut args: Vec = vec!["send-keys".into(), "-t".into(), sid.as_str().into()]; + match payload { + InputPayload::Text { text } => { args.push("-l".into()); args.push("--".into()); args.push(text); } + InputPayload::Keys { name } => { args.push("--".into()); args.push(name); } + InputPayload::Bytes { bytes_b64 } => { + use base64::Engine as _; + let raw = base64::engine::general_purpose::STANDARD.decode(&bytes_b64).map_err(|e| HostError::Other(e.to_string()))?; + let s = String::from_utf8_lossy(&raw).to_string(); + args.push("-l".into()); args.push("--".into()); args.push(s); + } + } + let st = Command::new("tmux").args(&args).status().await.map_err(HostError::Io)?; + if !st.success() { return Err(HostError::Other("tmux send-keys failed".into())); } + Ok(()) + } + + async fn capture_screen(&self, sid: &SessionId) -> Result { + let out = Command::new("tmux") + .args(["capture-pane", "-t", sid.as_str(), "-pJ", "-e"]) + .output().await.map_err(HostError::Io)?; + if !out.status.success() { + return Err(HostError::Other(format!("capture-pane failed: {}", String::from_utf8_lossy(&out.stderr)))); + } + // Also fetch rows/cols. + let dims = Command::new("tmux") + .args(["display-message", "-p", "-t", sid.as_str(), "#{pane_height},#{pane_width}"]) + .output().await.map_err(HostError::Io)?; + let s = String::from_utf8_lossy(&dims.stdout); + let (h, w): (u16, u16) = { + let mut parts = s.trim().split(','); + let h = parts.next().and_then(|p| p.parse().ok()).unwrap_or(24); + let w = parts.next().and_then(|p| p.parse().ok()).unwrap_or(80); + (h, w) + }; + Ok(ScreenSnapshot { rows: h, cols: w, ansi_bytes: out.stdout }) + } + + async fn resize(&self, sid: &SessionId, rows: u16, cols: u16) -> Result<(), HostError> { + Command::new("tmux").args(["resize-window", "-t", sid.as_str(), "-x", &cols.to_string(), "-y", &rows.to_string()]) + .status().await.map_err(HostError::Io)?; + Ok(()) + } + + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + // Our "attach" is a FIFO tailer task; dropping the receiver stops it. No tmux-side action. + Ok(()) + } + + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError> { + match mode { + StopMode::Graceful => { + Command::new("tmux").args(["send-keys", "-t", sid.as_str(), "--", "C-c"]).status().await.ok(); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + StopMode::Force => {} + } + Command::new("tmux").args(["kill-session", "-t", sid.as_str()]).status().await.map_err(HostError::Io)?; + let _ = std::fs::remove_file(self.fifo_path(sid)); + Ok(()) + } + + async fn status(&self) -> Result { + let out = Command::new("tmux").args(["list-sessions", "-F", "#{session_name}|#{session_created}"]).output().await; + let mut sessions = Vec::new(); + if let Ok(o) = out { + if o.status.success() { + for line in String::from_utf8_lossy(&o.stdout).lines() { + let mut parts = line.split('|'); + let name = parts.next().unwrap_or(""); + if !name.starts_with("claudette-") { continue; } + sessions.push(HostSessionSummary { + sid: SessionId(name.to_string()), + pid: None, + running: true, + }); + } + } + } + Ok(HostStatus { host_version: "tmux".into(), sessions }) + } +} + +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} +``` + +Add `nix = { version = "...", features = ["fs"] }` to the workspace deps if not already there. + +- [ ] **Step 4: Add to module list.** `src/agent/interactive_host/mod.rs` already has `#[cfg(unix)] pub mod tmux;` from Task B4. + +- [ ] **Step 5: Run.** On Unix: `cargo test -p claudette tmux_passes_conformance --ignored -- --nocapture`. Expected: PASS (skip-with-message if tmux not installed). + +- [ ] **Step 6: Clippy.** `cargo clippy -p claudette --all-targets --all-features` on Unix. Zero warnings. + +- [ ] **Step 7: Commit.** + +```bash +git add src/agent/interactive_host/tmux.rs Cargo.toml +git commit -m "feat(agent): TmuxHost passes InteractiveHost conformance" +``` + +--- + +### Task D2: Periodic reconciliation against tmux + +**Files:** +- Modify: `src/agent/interactive_host/tmux.rs` + +- [ ] **Step 1: Write failing test.** + +```rust +#[tokio::test] +#[ignore = "requires tmux >= 3.0"] +async fn reconciliation_marks_externally_killed_sessions_dead() { + // 1. Spawn a stub-tui session through TmuxHost::ensure_session. + // 2. Kill it externally via `tmux kill-session -t `. + // 3. Call host.reconcile() — observe that `status()` no longer lists it. +} +``` + +- [ ] **Step 2: Implement a `reconcile()` method** on `TmuxHost` that calls `status()` and returns the diff against an in-memory `expected` set. Provide a hook that callers (the lifecycle worker in `claude_interactive.rs`) can use to mark DB rows. + +```rust +impl TmuxHost { + pub async fn reconcile(&self, expected: &[SessionId]) -> Result, HostError> { + let st = self.status().await?; + let live: std::collections::HashSet<_> = st.sessions.iter().map(|s| s.sid.clone()).collect(); + Ok(expected.iter().filter(|sid| !live.contains(sid)).cloned().collect()) + } +} +``` + +- [ ] **Step 3: Run.** Expected: PASS. + +- [ ] **Step 4: Commit.** + +```bash +git add src/agent/interactive_host/tmux.rs +git commit -m "feat(agent): TmuxHost::reconcile returns sessions killed externally" +``` + +--- + +## Phase E — Hooks, CLI subcommand, IPC ingestion + +### Task E1: `claudette-cli chat hook` subcommand + +**Files:** +- Create: `src-cli/src/commands/chat_hook.rs` +- Modify: `src-cli/src/commands/mod.rs` and `src-cli/src/main.rs` (CLI parser) + +- [ ] **Step 1: Locate existing `chat` subcommand wiring.** Run `grep -n "chat" src-cli/src/main.rs src-cli/src/commands/*.rs` to find the parser. The new subcommand parallels existing `chat stop`, `chat answer`, etc. + +- [ ] **Step 2: Write a CLI parse test.** Append (or create) `src-cli/src/commands/chat_hook_tests.rs` (or `#[cfg(test)] mod tests` block): + +```rust +#[test] +fn parses_chat_hook_arguments() { + let parsed = crate::cli::parse_for_test(&[ + "claudette", "chat", "hook", + "--sid", "claudette-x-y", + "--kind", "awaiting", + "--reason", "blocked on permission", + ]).unwrap(); + match parsed.command { + crate::cli::Command::ChatHook(args) => { + assert_eq!(args.sid, "claudette-x-y"); + assert_eq!(args.kind, "awaiting"); + assert_eq!(args.reason.as_deref(), Some("blocked on permission")); + } + other => panic!("expected ChatHook, got {other:?}"), + } +} +``` + +(`parse_for_test` may or may not exist — match the existing pattern in the CLI tests; if there is none, follow whatever existing `chat answer` test does. The point is: parsing is covered by a test before adding code.) + +- [ ] **Step 3: Run.** Expected: FAIL. + +- [ ] **Step 4: Add the `ChatHookArgs` struct + `Command::ChatHook` variant** in the same place the existing `chat` subcommands are registered (use `clap` derive — pattern is already in the file). + +```rust +#[derive(Debug, clap::Args)] +pub struct ChatHookArgs { + #[arg(long)] + pub sid: String, + #[arg(long)] + pub kind: String, + #[arg(long)] + pub reason: Option, +} +``` + +Add the dispatch in `run.rs` (or wherever the CLI dispatches): + +```rust +Command::ChatHook(args) => { + let req = ChatHookRequest { + sid: args.sid, + kind: args.kind, + reason: args.reason, + // Hook payload from stdin (Claude Code passes JSON on stdin to hooks). + payload_stdin: read_stdin_to_string()?, + }; + let socket = resolve_claudette_socket()?; + send_ipc(&socket, IpcRequest::ChatHook(req))?; + Ok(()) +} +``` + +- [ ] **Step 5: Define `IpcRequest::ChatHook`** in the shared IPC types module (`grep -n "IpcRequest" src-tauri/src/ipc.rs src-cli/src` to find it). Add the new variant + serde tag. + +- [ ] **Step 6: Run tests, clippy, commit.** + +```bash +cargo test -p claudette-cli +cargo clippy -p claudette-cli --all-targets --all-features +git add src-cli src-tauri/src/ipc.rs +git commit -m "feat(cli): add 'chat hook' subcommand for Claude Code hook events" +``` + +--- + +### Task E2: Tauri-side hook ingestion + per-session event channel + +**Files:** +- Modify: `src-tauri/src/ipc.rs` +- Modify: `src-tauri/src/state.rs` + +- [ ] **Step 1: Write a failing test** at the bottom of `src-tauri/src/ipc.rs` (the file already has tests — match the style): + +```rust +#[tokio::test] +async fn chat_hook_dispatches_to_session_channel() { + let state = AppState::new_for_test(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + state.register_interactive_hook_channel("claudette-x-y", tx).await; + let req = IpcRequest::ChatHook(ChatHookRequest { + sid: "claudette-x-y".into(), + kind: "awaiting".into(), + reason: Some("perm".into()), + payload_stdin: "{\"hook_event_name\":\"Notification\"}".into(), + }); + handle_ipc_request(&state, req).await.unwrap(); + let got = rx.recv().await.unwrap(); + assert_eq!(got.sid, "claudette-x-y"); + matches!(got.kind, crate::state::HookEventKind::Awaiting); +} +``` + +- [ ] **Step 2: Implement.** Add to `state.rs`: + +```rust +#[derive(Debug, Clone)] +pub enum HookEventKind { + Stop, + Awaiting { reason: Option }, + PromptSubmitted, + SubagentStop, + Unknown { raw_kind: String }, +} + +#[derive(Debug, Clone)] +pub struct InteractiveHookEvent { + pub sid: String, + pub kind: HookEventKind, +} +``` + +Add a `HashMap>` to `AppState` (`Mutex` or `RwLock` per existing conventions). Add `register_interactive_hook_channel`, `unregister_interactive_hook_channel`, and `dispatch_interactive_hook`. + +In `ipc.rs`, the `IpcRequest::ChatHook(req)` arm parses `kind` and calls `state.dispatch_interactive_hook(req.sid, kind)`. + +- [ ] **Step 3: Run.** Expected: PASS. + +- [ ] **Step 4: Clippy, commit.** + +```bash +git add src-tauri/src/ipc.rs src-tauri/src/state.rs +git commit -m "feat(tauri): route 'chat hook' IPC into per-session interactive channels" +``` + +--- + +### Task E3: Settings overlay materialization + +**Files:** +- Create: `src/agent/claude_interactive.rs` (initial skeleton) + +- [ ] **Step 1: Test.** At the bottom of `src/agent/claude_interactive.rs` (create with this): + +```rust +//! ClaudeInteractive backend. +//! +//! Materializes a per-session settings overlay that registers Claude Code +//! hooks, then asks an `InteractiveHost` to spawn `claude` with +//! `CLAUDE_CONFIG_DIR` pointing at the overlay. + +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct SettingsOverlay { + pub dir: PathBuf, +} + +impl SettingsOverlay { + /// Create a fresh per-session overlay directory and write `settings.json` + /// registering hooks that call back via `cli_bin_abs` with `--sid `. + pub fn materialize(parent: &Path, sid: &str, cli_bin_abs: &Path) -> std::io::Result { + let dir = parent.join(sid).join("claude-config"); + std::fs::create_dir_all(&dir)?; + let settings = serde_json::json!({ + "hooks": { + "Stop": [ + { "matcher": "", "hooks": [ + { "type": "command", "command": format!("{} chat hook --sid {} --kind stop", + shell_quote(cli_bin_abs.to_string_lossy().as_ref()), sid) } + ]} + ], + "Notification": [ + { "matcher": "", "hooks": [ + { "type": "command", "command": format!("{} chat hook --sid {} --kind awaiting", + shell_quote(cli_bin_abs.to_string_lossy().as_ref()), sid) } + ]} + ], + "UserPromptSubmit": [ + { "matcher": "", "hooks": [ + { "type": "command", "command": format!("{} chat hook --sid {} --kind prompt_submitted", + shell_quote(cli_bin_abs.to_string_lossy().as_ref()), sid) } + ]} + ] + } + }); + std::fs::write(dir.join("settings.json"), serde_json::to_vec_pretty(&settings)?)?; + Ok(Self { dir }) + } + + pub fn cleanup(&self) -> std::io::Result<()> { + if self.dir.exists() { + std::fs::remove_dir_all(&self.dir)?; + } + Ok(()) + } +} + +fn shell_quote(s: &str) -> String { + if s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.')) { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_writes_settings_with_three_hooks() { + let dir = tempfile::tempdir().unwrap(); + let overlay = SettingsOverlay::materialize( + dir.path(), + "claudette-x-y", + Path::new("/abs/path/to/claudette-cli"), + ).unwrap(); + let json: serde_json::Value = serde_json::from_slice( + &std::fs::read(overlay.dir.join("settings.json")).unwrap() + ).unwrap(); + let hooks = json.get("hooks").unwrap(); + for key in ["Stop", "Notification", "UserPromptSubmit"] { + assert!(hooks.get(key).is_some(), "missing hook: {key}"); + } + overlay.cleanup().unwrap(); + assert!(!overlay.dir.exists()); + } +} +``` + +Note: The exact Claude Code hooks JSON schema (matcher / hooks / type / command) is what the existing `-p` path uses today via `AgentHookBridge`. Verify by reading `src/agent/args.rs::build_claude_args` and matching its overlay shape — copy that shape verbatim into the new overlay. + +- [ ] **Step 2: Add module + re-export.** In `src/agent/mod.rs`: + +```rust +pub mod claude_interactive; +``` + +- [ ] **Step 3: Run.** `cargo test -p claudette overlay_writes_settings_with_three_hooks`. Expected: PASS. + +- [ ] **Step 4: Clippy. Commit.** + +```bash +git add src/agent/claude_interactive.rs src/agent/mod.rs +git commit -m "feat(agent): settings overlay materializes Claude Code hooks" +``` + +--- + +## Phase F — Backend wiring + Tauri commands + +### Task F1: Add `ClaudeInteractive` variant to harness layer + +**Files:** +- Modify: `src/agent/harness.rs` +- Modify: `src/agent_backend.rs` + +- [ ] **Step 1: Test for capabilities.** In `src/agent/harness.rs`'s test block: + +```rust +#[test] +fn claude_interactive_capabilities() { + let c = AgentHarnessCapabilities::claude_interactive(); + assert!(c.persistent_sessions); + assert!(!c.attachments, "interactive mode does not yet support rich attachments"); +} +``` + +- [ ] **Step 2: Run.** Expected: FAIL. + +- [ ] **Step 3: Implement.** + +```rust +impl AgentHarnessCapabilities { + pub const fn claude_interactive() -> Self { + Self { + persistent_sessions: true, + steer_turn: false, // v1: no mid-turn steering through the interactive PTY. + host_permission_prompts: false, // claude handles them natively in its TUI. + remote_control: false, + mcp_config: true, + attachments: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentHarnessKind { + ClaudeCode, + ClaudeInteractive, + CodexAppServer, + #[cfg(feature = "pi-sdk")] + PiSdk, +} + +pub enum AgentSession { + ClaudeCode(PersistentSession), + ClaudeInteractive(crate::agent::claude_interactive::InteractiveSession), + CodexAppServer(CodexAppServerSession), + #[cfg(feature = "pi-sdk")] + PiSdk(PiSdkSession), +} +``` + +`InteractiveSession` does not exist yet — create a stub in `claude_interactive.rs`: + +```rust +pub struct InteractiveSession { + pub sid: String, +} +``` + +- [ ] **Step 4: Add the runtime in `src/agent_backend.rs`.** Locate `effective_harness` (the function the spec calls out). Add a new branch that maps to `AgentHarnessKind::ClaudeInteractive` when the user-selected runtime is `ClaudeInteractive`. **Only available when `claudeInteractiveEnabled` is true**, so the resolver also takes the flag as input (or reads it from the DB at call time). Match the existing resolver shape for how it consults settings. + +- [ ] **Step 5: Run all tests.** `cargo test -p claudette`. Then clippy. Both pass. + +- [ ] **Step 6: Commit.** + +```bash +git add src/agent/harness.rs src/agent/claude_interactive.rs src/agent_backend.rs +git commit -m "feat(agent): add ClaudeInteractive harness variant and backend resolver branch" +``` + +--- + +### Task F2: `InteractiveSession::start` (host selection + spawn + hook channel) + +**Files:** +- Modify: `src/agent/claude_interactive.rs` +- Modify: `src/agent/interactive_host/mod.rs` (small selection helper) + +- [ ] **Step 1: Test.** Append to `claude_interactive.rs`: + +```rust +#[cfg(test)] +mod start_tests { + use super::*; + + #[tokio::test] + #[ignore = "requires stub-tui binary"] + async fn start_creates_session_via_sidecar_host() { + // 1. Use SidecarHost pointing at the bundled session-host binary. + // 2. InteractiveSession::start with stub-tui as the claude_binary. + // 3. Assert sid matches expected format and hook channel is registered. + } +} +``` + +- [ ] **Step 2: Implement.** + +```rust +pub struct InteractiveSession { + pub sid: String, + pub host: Arc, + pub overlay: SettingsOverlay, +} + +impl InteractiveSession { + pub async fn start( + workspace_short: &str, + host: Arc, + spec: SessionSpec, + overlay_parent: &Path, + cli_bin_abs: &Path, + ) -> Result { + let sid_str = format!("claudette-{}-{}", workspace_short, random_hex8()); + let overlay = SettingsOverlay::materialize(overlay_parent, &sid_str, cli_bin_abs) + .map_err(|e| HostError::Other(e.to_string()))?; + let spec = SessionSpec { + claude_config_dir: overlay.dir.to_string_lossy().into(), + ..spec + }; + let sid = crate::agent::interactive_host::SessionId(sid_str.clone()); + host.ensure_session(&sid, &spec).await?; + Ok(Self { sid: sid_str, host, overlay }) + } +} + +fn random_hex8() -> String { + use rand::RngCore; + let mut buf = [0u8; 4]; + rand::thread_rng().fill_bytes(&mut buf); + buf.iter().map(|b| format!("{b:02x}")).collect() +} +``` + +Use the `rand` crate already in workspace deps (`grep -n "rand" Cargo.toml`). + +- [ ] **Step 3: Add host-selection helper** in `src/agent/interactive_host/mod.rs`: + +```rust +pub async fn select_default_host( + runtime_dir: &std::path::Path, + sidecar_socket: &std::path::Path, + sidecar_binary: &std::path::Path, + prefer_sidecar_on_unix: bool, +) -> Result, HostError> { + #[cfg(unix)] + if !prefer_sidecar_on_unix { + use availability::*; + match check_tmux().await { + TmuxAvailability::Available { .. } => { + return Ok(std::sync::Arc::new(tmux::TmuxHost::new(runtime_dir.to_path_buf()))); + } + _ => {} + } + } + Ok(std::sync::Arc::new(sidecar::SidecarHost::new(sidecar_socket.to_path_buf(), sidecar_binary.to_path_buf()))) +} +``` + +- [ ] **Step 4: Run.** Build, clippy. (Test is `#[ignore]`d for now — wire it up in F3.) Commit. + +```bash +git add src/agent/claude_interactive.rs src/agent/interactive_host/mod.rs +git commit -m "feat(agent): InteractiveSession::start with host selection" +``` + +--- + +### Task F3: Tauri commands for interactive sessions + +**Files:** +- Create: `src-tauri/src/commands/interactive.rs` +- Modify: `src-tauri/src/commands/mod.rs`, `src-tauri/src/state.rs`, `src-tauri/src/main.rs` (handler registration) + +- [ ] **Step 1: Tests live mostly in Rust integration tests for the underlying logic** (already covered). The Tauri command layer is thin — its job is parameter parsing + dispatch. Skip a unit test for these wrappers if the existing repo style is to do so (it largely is — `grep -L "tauri::test" src-tauri/src/commands` to confirm). + +- [ ] **Step 2: Implement commands:** + +```rust +//! Tauri commands for the Claude (Interactive) experimental backend. + +use crate::state::AppState; +use serde::{Deserialize, Serialize}; +use tauri::State; + +#[derive(Debug, Deserialize)] +pub struct StartInteractiveArgs { + pub workspace_id: String, + pub working_dir: String, + pub rows: u16, + pub cols: u16, + pub claude_binary: String, + pub claude_args: Vec, +} + +#[derive(Debug, Serialize)] +pub struct StartInteractiveResult { + pub sid: String, + pub host_kind: String, +} + +#[tauri::command] +pub async fn interactive_start( + state: State<'_, AppState>, + args: StartInteractiveArgs, +) -> Result { + // Flag check. + if !state.claude_interactive_enabled().await { + return Err("Claude Interactive is disabled".into()); + } + // Resolve host, overlay parent, CLI binary path. + let host = state.interactive_host_for(&args.workspace_id).await + .map_err(|e| e.to_string())?; + let overlay_parent = state.runtime_dir_for_interactive().await; + let cli_bin = state.bundled_cli_binary_path().await + .ok_or_else(|| "claudette-cli binary not found".to_string())?; + let sess = claudette::agent::claude_interactive::InteractiveSession::start( + workspace_short(&args.workspace_id), + host.clone(), + claudette::agent::interactive_protocol::SessionSpec { + working_dir: args.working_dir.clone(), + rows: args.rows, cols: args.cols, + claude_binary: args.claude_binary, + claude_args: args.claude_args, + env: vec![], + claude_config_dir: String::new(), // overlay populates this + }, + &overlay_parent, + &cli_bin, + ).await.map_err(|e| e.to_string())?; + // Persist row. + let db = claudette::db::Database::open(&state.db_path).map_err(|e| e.to_string())?; + db.create_interactive_session(&claudette::db::InteractiveSessionRow { + sid: sess.sid.clone(), + workspace_id: args.workspace_id.clone(), + host_kind: state.interactive_host_kind_for(&args.workspace_id).await.to_string(), + state: "running".into(), + crash_reason: None, + created_at: chrono::Utc::now().to_rfc3339(), + last_attached_at: None, + last_screen_blob: None, + claude_flags_json: serde_json::to_string(&args.claude_args).unwrap(), + pid: None, + }).map_err(|e| e.to_string())?; + state.register_interactive_session(&sess.sid, args.workspace_id.clone()).await; + Ok(StartInteractiveResult { sid: sess.sid, host_kind: "tmux-or-sidecar".into() }) +} + +#[tauri::command] +pub async fn interactive_send_input( + state: State<'_, AppState>, + sid: String, + text: String, +) -> Result<(), String> { + let host = state.host_for_session(&sid).await.ok_or_else(|| "session not found".to_string())?; + host.send_input( + &claudette::agent::interactive_host::SessionId(sid), + claudette::agent::interactive_protocol::InputPayload::Text { text }, + ).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn interactive_capture_screen(state: State<'_, AppState>, sid: String) -> Result { + use base64::Engine as _; + let host = state.host_for_session(&sid).await.ok_or_else(|| "session not found".to_string())?; + let snap = host.capture_screen(&claudette::agent::interactive_host::SessionId(sid.clone())) + .await.map_err(|e| e.to_string())?; + // Persist to DB for instant repaint on next launch. + let db = claudette::db::Database::open(&state.db_path).map_err(|e| e.to_string())?; + db.update_interactive_session_screen(&sid, &snap.ansi_bytes).map_err(|e| e.to_string())?; + Ok(base64::engine::general_purpose::STANDARD.encode(&snap.ansi_bytes)) +} + +#[tauri::command] +pub async fn interactive_stop(state: State<'_, AppState>, sid: String, force: bool) -> Result<(), String> { + let host = state.host_for_session(&sid).await.ok_or_else(|| "session not found".to_string())?; + let mode = if force { + claudette::agent::interactive_protocol::StopMode::Force + } else { + claudette::agent::interactive_protocol::StopMode::Graceful + }; + host.stop(&claudette::agent::interactive_host::SessionId(sid.clone()), mode) + .await.map_err(|e| e.to_string())?; + let db = claudette::db::Database::open(&state.db_path).map_err(|e| e.to_string())?; + db.set_interactive_session_state(&sid, "stopped", None).map_err(|e| e.to_string())?; + state.unregister_interactive_session(&sid).await; + Ok(()) +} + +#[tauri::command] +pub async fn interactive_list_for_workspace( + state: State<'_, AppState>, + workspace_id: String, +) -> Result, String> { + let db = claudette::db::Database::open(&state.db_path).map_err(|e| e.to_string())?; + db.list_interactive_sessions_for_workspace(&workspace_id).map_err(|e| e.to_string()) +} +``` + +Implementations for each follow the same pattern — call into `state.interactive_host_for(...)` then forward to the trait method. + +- [ ] **Step 3: Register commands** in `src-tauri/src/main.rs` `tauri::generate_handler!` macro (alphabetical / by-domain — match existing order). + +- [ ] **Step 4: Emit events to the webview.** Add an `attach` Tauri command that spawns a Tokio task subscribing to the `AttachStream` and emitting Tauri events `interactive:///output` (base64) and `interactive:///hook` (json). Reuse the existing `tauri::AppHandle::emit` pattern from `commands/chat.rs`. + +- [ ] **Step 5: Build + clippy** the full app (Tauri included on the dev machine). + +```bash +cd src-tauri && cargo build --features tauri/custom-protocol,server,voice,devtools,alternative-backends,pi-sdk +cargo clippy -p claudette -p claudette-server -p claudette-cli --all-targets --all-features +``` + +Expected: zero warnings, builds succeed. + +- [ ] **Step 6: Commit.** + +```bash +git add src-tauri +git commit -m "feat(tauri): interactive_start/send_input/attach/capture_screen/stop commands" +``` + +--- + +### Task F4: Bundle `claudette-session-host` via `externalBin` + +**Files:** +- Modify: `src-tauri/Cargo.toml` (add `claudette-session-host` to `[dependencies]` or `[build-dependencies]` as needed for staging) +- Modify: `src-tauri/tauri.conf.json` (`bundle.externalBin`) +- Create / modify: `scripts/stage-session-host-sidecar.sh` (mirror the pi-harness staging script's shape) +- Modify: `scripts/stage-cli-sidecar.sh` (chain to the new script, mirroring how it chains to `stage-pi-harness-sidecar.sh`) + +- [ ] **Step 1: Add the externalBin entry** in `tauri.conf.json`: + +```json +{ + "bundle": { + "externalBin": [ + "binaries/claudette-cli", + "binaries/claudette-pi-harness", + "binaries/claudette-session-host" + ] + } +} +``` + +- [ ] **Step 2: Stage script.** Create `scripts/stage-session-host-sidecar.sh` (copy from `scripts/stage-cli-sidecar.sh`): + +```bash +#!/usr/bin/env bash +set -euo pipefail +TARGET_TRIPLE=$(rustc -vV | sed -n 's/host: //p') +TARGET_DIR=${CARGO_TARGET_DIR:-target} +mkdir -p src-tauri/binaries +cargo build -p claudette-session-host --release +src=${TARGET_DIR}/release/claudette-session-host$(rustc -vV | sed -n 's/host: //p' | grep -q windows && echo .exe || echo "") +dest=src-tauri/binaries/claudette-session-host-${TARGET_TRIPLE}$(echo "$src" | grep -q '\.exe$' && echo .exe || echo "") +cp -f "$src" "$dest" +echo "staged $dest" +``` + +(Cross-platform `.exe` suffix logic mirrors `stage-pi-harness-sidecar.sh` — check that file's actual shape and copy.) + +- [ ] **Step 3: Chain from `stage-cli-sidecar.sh`** so `scripts/dev.sh`'s existing chain stages all three sidecars in one shot. + +- [ ] **Step 4: Modify `scripts/macos-dev-app-runner.sh`** to also copy the staged session-host binary into the dev `.app`'s `Contents/MacOS/` next to the CLI / pi-harness. + +- [ ] **Step 5: Verify staging.** Run: `bash scripts/stage-cli-sidecar.sh`. Expected: prints `staged …/claudette-session-host-…`. + +- [ ] **Step 6: Run the app in dev.** `./scripts/dev.sh`. Verify the binary is alongside the others in the dev `.app`. + +- [ ] **Step 7: Commit.** + +```bash +git add src-tauri scripts +git commit -m "build(tauri): bundle claudette-session-host as externalBin sidecar" +``` + +--- + +## Phase G — UI integration + +### Task G1: Experimental flag row in Settings + +**Files:** +- Modify: `src/ui/src/components/settings/sections/ExperimentalSettings.tsx` +- Modify: `src/ui/src/components/settings/sections/ExperimentalSettings.test.tsx` (or sibling test if test naming differs) + +- [ ] **Step 1: Find the existing pattern.** `grep -n "pluginManagementEnabled" src/ui/src/components/settings/sections/ExperimentalSettings.tsx`. + +- [ ] **Step 2: Write failing test.** Add to the test file: + +```tsx +it("toggles claudeInteractiveEnabled when the switch is clicked", async () => { + const user = userEvent.setup(); + render(); + const sw = screen.getByLabelText(/claude.*interactive/i); + expect(sw).not.toBeChecked(); + await user.click(sw); + expect(useAppStore.getState().settings.claudeInteractiveEnabled).toBe(true); +}); +``` + +- [ ] **Step 3: Run.** `cd src/ui && bun run test -t "claudeInteractiveEnabled"`. Expected: FAIL. + +- [ ] **Step 4: Add the row to the component**, copying the `pluginManagementEnabled` row exactly: + +```tsx + setSetting("claudeInteractiveEnabled", v)} +/> +``` + +- [ ] **Step 5: Run test + tsc + lint + css-lint.** All pass. Commit. + +```bash +cd src/ui && bunx tsc -b && bun run lint && bun run lint:css && bun run test +cd ../.. +git add src/ui/src/components/settings/sections/ExperimentalSettings.tsx src/ui/src/components/settings/sections/ExperimentalSettings.test.tsx +git commit -m "feat(ui): add Claude (Interactive) toggle to Experimental settings" +``` + +--- + +### Task G2: Runtime card for Claude (Interactive) in Models settings + +**Files:** +- Modify: `src/ui/src/components/settings/sections/ModelsSettings.tsx` (or wherever the Runtime sub-section lives — search `Runtime`) +- Modify: matching test + +- [ ] **Step 1: Test.** Add to the Runtime section's test file (e.g., `ModelsSettings.test.tsx`): + +```tsx +import { render, screen } from "@testing-library/react"; +import { useAppStore } from "../../../store"; + +it("renders Interactive runtime card disabled when claudeInteractiveEnabled is false", () => { + useAppStore.setState({ settings: { ...useAppStore.getState().settings, claudeInteractiveEnabled: false } }); + render(); + const card = screen.getByTestId("runtime-card-claude-interactive"); + expect(card).toHaveAttribute("aria-disabled", "true"); + expect(card).toHaveTextContent(/enable in experimental/i); +}); + +it("renders Interactive runtime card selectable when flag is on", () => { + useAppStore.setState({ settings: { ...useAppStore.getState().settings, claudeInteractiveEnabled: true } }); + render(); + const card = screen.getByTestId("runtime-card-claude-interactive"); + expect(card).not.toHaveAttribute("aria-disabled", "true"); +}); +``` + +- [ ] **Step 2: Implement.** Add a new card matching the existing Runtime card pattern (find an existing one in `ModelsSettings.tsx` and duplicate its JSX shape — Ollama or Codex Native is a good template). Set `data-testid="runtime-card-claude-interactive"`, `aria-disabled={!settings.claudeInteractiveEnabled}` and conditionally render the "Enable in Experimental" text when disabled. Click-handler must noop when disabled. + +- [ ] **Step 3: Run tests + tsc + lint. Commit.** + +```bash +cd src/ui && bunx tsc -b && bun run lint && bun run lint:css && bun run test +cd ../.. +git add src/ui/src/components/settings/sections/ModelsSettings.tsx +git commit -m "feat(ui): expose Claude (Interactive) runtime card gated by experimental flag" +``` + +--- + +### Task G3: Tauri bridge service `interactive.ts` + +**Files:** +- Create: `src/ui/src/services/interactive.ts` +- Create: `src/ui/src/services/interactive.test.ts` + +- [ ] **Step 1: Define the API surface** in `services/interactive.ts`: + +```ts +import { invoke } from "@tauri-apps/api/core"; +import { listen, UnlistenFn } from "@tauri-apps/api/event"; + +export interface StartInteractiveArgs { + workspaceId: string; + workingDir: string; + rows: number; + cols: number; + claudeBinary: string; + claudeArgs: string[]; +} + +export interface StartInteractiveResult { + sid: string; + hostKind: string; +} + +export async function startInteractive(args: StartInteractiveArgs): Promise { + return invoke("interactive_start", { args }); +} + +export async function sendInput(sid: string, text: string): Promise { + return invoke("interactive_send_input", { sid, text }); +} + +export async function captureScreen(sid: string): Promise { + return invoke("interactive_capture_screen", { sid }); +} + +export async function stopInteractive(sid: string, force = false): Promise { + return invoke("interactive_stop", { sid, force }); +} + +export interface OutputEvent { sid: string; bytesB64: string; seq: number; } +export interface HookEvent { sid: string; kind: "stop" | "awaiting" | "prompt_submitted" | "subagent_stop" | "unknown"; reason?: string; } + +export async function subscribeOutput(sid: string, fn: (ev: OutputEvent) => void): Promise { + return listen(`interactive://${sid}/output`, (e) => fn(e.payload)); +} +export async function subscribeHooks(sid: string, fn: (ev: HookEvent) => void): Promise { + return listen(`interactive://${sid}/hook`, (e) => fn(e.payload)); +} +``` + +- [ ] **Step 2: Vitest mock.** In the test file, mock `@tauri-apps/api/core` and `@tauri-apps/api/event`, then assert each function calls the right invoke command with the right payload. Run, expect PASS. Commit. + +```bash +cd src/ui && bunx tsc -b && bun run lint && bun run test +cd ../.. +git add src/ui/src/services/interactive.ts src/ui/src/services/interactive.test.ts +git commit -m "feat(ui): add interactive Tauri bridge service" +``` + +--- + +### Task G4: Hook-delimited turn assembler + +**Files:** +- Create: `src/ui/src/hooks/useInteractiveTurnAssembler.ts` +- Create: `src/ui/src/hooks/useInteractiveTurnAssembler.test.ts` + +- [ ] **Step 1: Test cases** to write before the code (all in one file): + +```ts +describe("useInteractiveTurnAssembler", () => { + it("emits a turn on Stop", () => { /* feed UserPromptSubmit, OUTPUT, Stop; assert one turn collected */ }); + it("clears awaiting badge when UserPromptSubmit fires", () => { /* feed Awaiting then UserPromptSubmit; assert badge cleared */ }); + it("ignores duplicate awaiting events", () => { /* feed Awaiting, Awaiting; assert flag is true only once */ }); + it("flips to crashed state on Exit event", () => { /* feed Exit; assert state.crashed = true */ }); + it("falls back to raw_kind for unknown hooks", () => { /* feed unknown kind; assert turn still progresses */ }); +}); +``` + +- [ ] **Step 2: Run.** `cd src/ui && bun run test -t useInteractiveTurnAssembler`. Expected: FAIL. + +- [ ] **Step 3: Implement** as a pure reducer (`assemblerReducer(state, ev) -> newState`) + a thin React hook around `useReducer`. Reducer takes: + +```ts +type Event = + | { type: "output"; bytes: Uint8Array; seq: number } + | { type: "hook"; kind: HookEvent["kind"]; reason?: string } + | { type: "exit"; reason: string }; + +interface AssemblerState { + turns: { id: number; bytes: Uint8Array; status: "live" | "done" | "crashed" }[]; + awaitingInput: boolean; + crashed: boolean; +} +``` + +Reducer logic: `UserPromptSubmit` opens a new turn; `OUTPUT` appends to current turn (or to a transient "before first prompt" turn); `Stop` marks current turn done; `Awaiting` flips `awaitingInput`; `UserPromptSubmit` clears `awaitingInput`; `Exit` marks current turn crashed and sets `state.crashed`. + +- [ ] **Step 4: Run, tsc, lint. Commit.** + +```bash +cd src/ui && bunx tsc -b && bun run lint && bun run test -t useInteractiveTurnAssembler +cd ../.. +git add src/ui/src/hooks/useInteractiveTurnAssembler.ts src/ui/src/hooks/useInteractiveTurnAssembler.test.ts +git commit -m "feat(ui): hook-delimited turn assembler for interactive sessions" +``` + +--- + +### Task G5: Per-turn embedded xterm.js view + +**Files:** +- Create: `src/ui/src/components/chat/InteractiveTurnView.tsx` +- Create: `src/ui/src/components/chat/InteractiveTurnView.test.tsx` + +- [ ] **Step 1: Test outline.** + +```tsx +it("writes incoming bytes into xterm.js", async () => { + const { container } = render(); + await waitFor(() => expect(container.textContent).toContain("hello")); +}); +``` + +xterm.js writes asynchronously — use `waitFor`. + +- [ ] **Step 2: Implement.** Use the same imports as `TerminalPanel.tsx` (`@xterm/xterm`, `@xterm/addon-fit`). One xterm.js instance per `` mount; on unmount, dispose. Resize via `FitAddon`. + +- [ ] **Step 3: Run, tsc, lint. Commit.** + +```bash +cd src/ui && bunx tsc -b && bun run lint && bun run test -t InteractiveTurnView +cd ../.. +git add src/ui/src/components/chat/InteractiveTurnView.tsx src/ui/src/components/chat/InteractiveTurnView.test.tsx +git commit -m "feat(ui): InteractiveTurnView renders per-turn xterm.js" +``` + +--- + +### Task G6: ChatPanel conditional render + full-terminal toggle + +**Files:** +- Modify: `src/ui/src/components/chat/ChatPanel.tsx` (god file — only the minimal hook here) +- Create: `src/ui/src/components/chat/InteractiveTerminalMode.tsx` (full-terminal view) +- Modify: matching tests + +- [ ] **Step 1: Check the god-file rule.** `ChatPanel.tsx` is on the avoid-piling-on list. The minimum here is to read `useInteractiveTurnAssembler` and render `InteractiveTurnView` per turn — that's two new imports and one conditional. Acceptable. The full-terminal view is its own new file. + +- [ ] **Step 2: Tests.** Mock the bridge + assembler; render ChatPanel with `backend.kind === "ClaudeInteractive"` and assert it renders `InteractiveTurnView`s. Render with `backend.kind === "ClaudeCode"` and assert the existing render path still works (regression). + +- [ ] **Step 3: Implement.** In ChatPanel, after the existing turn-render loop, add a branch: + +```tsx +{backend.kind === "ClaudeInteractive" ? ( + +) : ( + /* existing rendering */ +)} +``` + +`InteractiveTurns` is a small sibling component (also new) wrapping `useInteractiveTurnAssembler` + the list of `InteractiveTurnView`s. Putting it in a sibling keeps ChatPanel diff small. + +- [ ] **Step 4: Implement `InteractiveTerminalMode.tsx`** — full-size xterm.js attached to the same sid, plus a "Back to chat" button. Triggered from the chat header (modify the header component — find it via `grep -rn "ChatHeader" src/ui/src/components/chat`). + +- [ ] **Step 5: Run, tsc, lint, css-lint. Commit.** + +```bash +cd src/ui && bunx tsc -b && bun run lint && bun run lint:css && bun run test +cd ../.. +git add src/ui/src/components/chat +git commit -m "feat(ui): wire ChatPanel to interactive backend (embedded + full-terminal modes)" +``` + +--- + +### Task G7: Sidebar badges (Awaiting / Detached / Crashed) + +**Files:** +- Modify: `src/ui/src/components/sidebar/Sidebar.tsx` (god file — be surgical) +- Create: `src/ui/src/components/sidebar/InteractiveBadge.tsx` (so the diff to Sidebar.tsx is one import + one render) +- Modify: matching test + +- [ ] **Step 1: Test.** Render `InteractiveBadge` with each state and assert the rendered text/title. + +- [ ] **Step 2: Implement** the component (~40 lines), reusing the existing attention-badge color tokens (`var(--accent-attention)`, `var(--muted)`, etc.). + +- [ ] **Step 3: Wire into Sidebar.tsx** with a single new import + one conditional `` next to the row's existing badge slot. + +- [ ] **Step 4: Run, tsc, lint, css-lint. Commit.** + +```bash +git add src/ui/src/components/sidebar +git commit -m "feat(ui): sidebar badges for interactive session state" +``` + +--- + +### Task G8: `tmux attach` copy-string menu item (Unix) + +**Files:** +- Modify: wherever the workspace/session context menu is built (search `Workspace.*ContextMenu` in `src/ui/src/components/sidebar`) + +- [ ] **Step 1: Test** the menu rendering: when `hostKind === "tmux"` and on Unix, the "Copy `tmux attach` command" item appears; otherwise it doesn't. + +- [ ] **Step 2: Implement.** Compose the command: + +```ts +const cmd = `tmux attach-session -t ${sid}`; +await navigator.clipboard.writeText(cmd); +toast.info(`Copied: ${cmd}`); +``` + +Detect OS via `import.meta.env.TAURI_PLATFORM` or a Tauri command — match how the rest of the app handles OS gating. + +- [ ] **Step 3: Run, commit.** + +```bash +git add src/ui/src/components/sidebar +git commit -m "feat(ui): copy tmux attach command for interactive sessions on Unix" +``` + +--- + +## Phase H — Lifecycle: reattach, cleanup, orphans + +### Task H1: Reattach-on-startup + +**Files:** +- Modify: `src-tauri/src/state.rs` or the existing startup function in `src-tauri/src/main.rs`/`commands/workspace.rs` + +- [ ] **Step 1: Test.** Use a mock host so the test doesn't need a real tmux/sidecar: + +```rust +#[tokio::test] +async fn reattach_on_startup_classifies_rows() { + use claudette::agent::interactive_host::{HostSessionSummary, HostStatus, InteractiveHost, SessionId}; + use std::sync::Arc; + + let dir = tempfile::tempdir().unwrap(); + let db = claudette::db::Database::open(&dir.path().join("t.db")).unwrap(); + db.run_migrations().unwrap(); + db.insert_workspace_for_test("ws-1").unwrap(); + for (sid, state) in [ + ("claudette-ws1-aaaaaaaa", "running"), + ("claudette-ws1-bbbbbbbb", "running"), + ("claudette-ws1-cccccccc", "detached"), + ] { + db.create_interactive_session(&claudette::db::InteractiveSessionRow { + sid: sid.into(), + workspace_id: "ws-1".into(), + host_kind: "tmux".into(), + state: state.into(), + crash_reason: None, + created_at: "2026-05-16T00:00:00Z".into(), + last_attached_at: None, + last_screen_blob: None, + claude_flags_json: "[]".into(), + pid: None, + }).unwrap(); + } + + // Host knows about A and C only. + let host: Arc = Arc::new(MockHost { + status_response: HostStatus { + host_version: "mock".into(), + sessions: vec![ + HostSessionSummary { sid: SessionId("claudette-ws1-aaaaaaaa".into()), pid: None, running: true }, + HostSessionSummary { sid: SessionId("claudette-ws1-cccccccc".into()), pid: None, running: true }, + ], + }, + }); + + crate::interactive::reattach_pending(&db, &host).await.unwrap(); + + assert_eq!(db.get_interactive_session("claudette-ws1-aaaaaaaa").unwrap().unwrap().state, "detached"); + let b = db.get_interactive_session("claudette-ws1-bbbbbbbb").unwrap().unwrap(); + assert_eq!(b.state, "crashed"); + assert_eq!(b.crash_reason.as_deref(), Some("host missing")); + assert_eq!(db.get_interactive_session("claudette-ws1-cccccccc").unwrap().unwrap().state, "detached"); +} + +// Minimal mock — paste alongside the test. +struct MockHost { status_response: claudette::agent::interactive_host::HostStatus } +#[async_trait::async_trait] +impl claudette::agent::interactive_host::InteractiveHost for MockHost { + async fn ensure_session(&self, _: &claudette::agent::interactive_host::SessionId, _: &claudette::agent::interactive_protocol::SessionSpec) -> Result { unimplemented!() } + async fn attach(&self, _: &claudette::agent::interactive_host::SessionId) -> Result<(claudette::agent::interactive_host::AttachId, claudette::agent::interactive_host::AttachStream), claudette::agent::interactive_host::HostError> { unimplemented!() } + async fn send_input(&self, _: &claudette::agent::interactive_host::SessionId, _: claudette::agent::interactive_protocol::InputPayload) -> Result<(), claudette::agent::interactive_host::HostError> { unimplemented!() } + async fn capture_screen(&self, _: &claudette::agent::interactive_host::SessionId) -> Result { unimplemented!() } + async fn resize(&self, _: &claudette::agent::interactive_host::SessionId, _: u16, _: u16) -> Result<(), claudette::agent::interactive_host::HostError> { unimplemented!() } + async fn detach(&self, _: &claudette::agent::interactive_host::SessionId, _: claudette::agent::interactive_host::AttachId) -> Result<(), claudette::agent::interactive_host::HostError> { unimplemented!() } + async fn stop(&self, _: &claudette::agent::interactive_host::SessionId, _: claudette::agent::interactive_protocol::StopMode) -> Result<(), claudette::agent::interactive_host::HostError> { unimplemented!() } + async fn status(&self) -> Result { Ok(self.status_response.clone()) } +} +``` + +- [ ] **Step 2: Implement.** Iterate over `interactive_sessions` where `state = 'running'`. For each, call `host.status()` once (cached per host_kind). For each row: if host knows about it, mark `detached` (we will re-attach on workspace open); else mark `crashed`. + +- [ ] **Step 3: Run, clippy, commit.** + +```bash +git add src-tauri +git commit -m "feat(tauri): reattach surviving interactive sessions on startup" +``` + +--- + +### Task H2: Per-workspace cleanup on archive/delete + +**Files:** +- Modify: the existing workspace-archive / workspace-delete code path (`grep -n "archive_workspace\|delete_workspace" src-tauri/src/commands`) + +- [ ] **Step 1: Test.** Insert an `interactive_sessions` row pointing at a workspace; call the workspace-delete command (or its inner function); assert the row is gone AND that the host's `stop` was called (mock the host in test). + +- [ ] **Step 2: Implement** the call chain: before deleting workspace row, enumerate interactive sessions for that workspace and call `host.stop(sid, Graceful)` on each. The DB `ON DELETE CASCADE` then removes the rows. + +- [ ] **Step 3: Run, commit.** + +```bash +git add src-tauri +git commit -m "feat(tauri): stop interactive sessions when workspaces are deleted" +``` + +--- + +### Task H3: Orphaned session detection + +**Files:** +- Modify: same startup function as Task H1 + +- [ ] **Step 1: Test.** Host reports a `claudette--` session that the DB has no row for → state contains an `orphans` list with that sid. + +- [ ] **Step 2: Implement.** During startup, compute `host_sessions - db_sessions` for sessions matching the `claudette-` prefix. Surface them as a tray notification + clean-up action. + +- [ ] **Step 3: UI side.** Add a one-shot toast / banner with a "Clean up" button that calls a new `interactive_cleanup_orphans` command (which iterates and stops each). + +- [ ] **Step 4: Run, commit.** + +```bash +git add src-tauri src/ui +git commit -m "feat(interactive): detect and clean up orphaned interactive sessions" +``` + +--- + +## Phase I — Docs + alignment files + +### Task I1: User-facing docs page + +**Files:** +- Create: `site/src/content/docs/features/interactive-claude.mdx` +- Modify: `site/astro.config.mjs` (sidebar nav) +- Modify: `site/src/content/docs/features/settings.mdx` (reference table row) + +- [ ] **Step 1: Write the docs page** using this scaffold (fill prose around each heading; do not leave any heading without content): + +```mdx +--- +title: Claude (Interactive) +description: Run interactive claude inside a detachable host that survives Claudette closing. +--- + +import { Aside } from "@astrojs/starlight/components"; + + + +## What it is + +Claude (Interactive) is an alternate agent backend that runs the interactive +`claude` TUI (no `--print`) inside a host process that outlives Claudette: +- On macOS and Linux: tmux (≥ 3.0). +- On Windows: a bundled sidecar `claudette-session-host`. + +Sessions survive closing Claudette, OS sleep, and Claudette crashes. They do +not survive a host crash (tmux server kill, sidecar crash). + +## When to use it + +- You want claude's native plan-mode and slash-command UI. +- You want to keep a long-running session open across Claudette restarts. +- On Unix, you want to attach to the same session from an external terminal. + +## When not to use it + +- You rely on attachments (images, files) sent through the chat composer — + v1 does not pipe stream-json into interactive claude; use slash commands + (`/file …`) instead. +- You want fully cross-machine session sharing — v1 is local-only. + +## Enabling it + +1. Settings → Experimental → toggle **Claude (Interactive)** on. +2. Open a workspace → Settings → Models → Runtime → pick **Claude (Interactive)**. +3. (Unix only) Confirm `tmux -V` reports ≥ 3.0; otherwise install tmux first. + +## Hooks that fire + +Claudette registers three Claude Code hooks via a transient +`CLAUDE_CONFIG_DIR` overlay: + +| Hook | Effect inside Claudette | +|---|---| +| `Stop` | Marks the turn complete. | +| `Notification` | Surfaces an "Awaiting input" badge + OS notification. | +| `UserPromptSubmit` | Clears the awaiting badge and starts a new turn. | + +The overlay is removed when the session is stopped. + +## Attaching from an external terminal (Unix) + +Right-click the session in the sidebar → **Copy tmux attach command**, then +paste into any terminal: + +```bash +tmux attach-session -t claudette-- +``` + + + +## Stopping a session + +Sidebar → session row → **Stop**. Graceful by default (`Ctrl+C`, then SIGTERM, then SIGKILL). +The "Force" link in the confirm dialog skips straight to SIGKILL. + +## Crash recovery + +If tmux or the sidecar dies, sessions are marked **Crashed**. Start a new +session from the same workspace — there is no recovery beyond what claude's +own `/resume` provides. + +## Limitations (v1) + +- Attachments (image / file uploads) are not routed through the chat composer. +- A separate xterm.js renders the session — native renderer is a follow-up. +- External tmux input can confuse turn assembly (documented above). +``` + +- [ ] **Step 2: Add sidebar entry** to `site/astro.config.mjs`. Use the existing pattern for `features/diagnostics.mdx` as the template. + +- [ ] **Step 3: Add the settings row** to `settings.mdx` under the appropriate `## Section`. Include flag name, default, what it gates. + +- [ ] **Step 4: Build the docs site** to verify it compiles. Run (from `site/`): `bun install && bun run build`. Expected: succeeds. + +- [ ] **Step 5: Commit.** + +```bash +git add site +git commit -m "docs(site): describe interactive-claude experimental backend" +``` + +--- + +### Task I2: CLAUDE.md sync + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `.github/copilot-instructions.md` + +- [ ] **Step 1: Add a section** to `CLAUDE.md` under the agent-integration paragraph documenting: + + - The new `ClaudeInteractive` harness kind, gated by `claudeInteractiveEnabled`. + - The two-host model (tmux on Unix, `claudette-session-host` sidecar on Windows + opt-in Unix fallback). + - Where the wire protocol lives (`src/agent/interactive_protocol.rs`). + - Hook plumbing (`claudette-cli chat hook` → IPC ingest in `src-tauri/src/ipc.rs`). + - Add `src-session-host/` to the crate table. + - Add `claudette-session-host` to the externalBin list. + - Add `InteractiveTurnView.tsx`, the new chat surface, and the assembler hook to the frontend tour. + - Add the experimental flag and Models > Runtime card under settings. + +- [ ] **Step 2: Mirror the same changes in `.github/copilot-instructions.md`** per the alignment rule in CLAUDE.md. + +- [ ] **Step 3: Commit.** + +```bash +git add CLAUDE.md .github/copilot-instructions.md +git commit -m "docs: document interactive-claude backend in CLAUDE.md + copilot instructions" +``` + +--- + +## Self-review checklist (run after every Phase) + +After completing each Phase A–I, run **all four** before marking it done: + +1. `cargo fmt --all` — must produce zero diff. +2. `cargo clippy -p claudette -p claudette-server -p claudette-cli -p claudette-session-host --all-targets --all-features` — zero warnings. +3. `cargo test -p claudette -p claudette-server -p claudette-cli -p claudette-session-host --all-features` — all pass. +4. `cd src/ui && bunx tsc -b && bun run lint && bun run lint:css && bun run test` — all pass. + +On a Unix machine, additionally run: `cargo test -p claudette tmux_passes_conformance --ignored -- --nocapture`. + +On any machine with the session-host binary built, additionally run: `cargo test -p claudette sidecar_passes_conformance --ignored -- --nocapture`. + +--- + +## Done criteria + +The feature is shippable when **all** of these are true: + +- All tasks A1–I2 are checked. +- `claudeInteractiveEnabled` defaults to `false` in a fresh install (Task A1 / A2). +- With the flag on and tmux installed, manual flow on macOS or Linux works end-to-end: create workspace → switch backend → send a turn → see hook badge → close Claudette → reopen → see reattached session. +- With the flag on and no tmux installed, Unix users see a clear "install tmux" message and the sidecar fallback works. +- Windows manual flow works against the sidecar. +- The user-facing docs page and the settings reference row exist and the site builds. +- CLAUDE.md and `.github/copilot-instructions.md` describe the new backend. +- CI passes (Rust + frontend, all platforms). From 572127acbb46f36d399da08ffca7c1127a2fac78 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 18:49:52 -0700 Subject: [PATCH 03/95] feat(settings): add claudeInteractiveEnabled experimental flag --- src/ui/src/stores/slices/settingsSlice.ts | 9 +++++++++ src/ui/src/stores/useAppStore.test.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/ui/src/stores/slices/settingsSlice.ts b/src/ui/src/stores/slices/settingsSlice.ts index c2c505720..bfcd1b767 100644 --- a/src/ui/src/stores/slices/settingsSlice.ts +++ b/src/ui/src/stores/slices/settingsSlice.ts @@ -65,6 +65,12 @@ export interface SettingsSlice { // Experimental usageInsightsEnabled: boolean; setUsageInsightsEnabled: (enabled: boolean) => void; + /// Experimental: surface the ClaudeInteractive agent backend, which runs + /// the `claude` CLI in interactive (non `--print`) mode inside a + /// detachable host (tmux/sidecar). Off by default; opt in via + /// Settings → Experimental. + claudeInteractiveEnabled: boolean; + setClaudeInteractiveEnabled: (enabled: boolean) => void; disable1mContext: boolean; setDisable1mContext: (v: boolean) => void; alternativeBackendsAvailable: boolean; @@ -187,6 +193,9 @@ export const createSettingsSlice: StateCreator< set({ projectViewIssuesPrsEnabled: enabled }), usageInsightsEnabled: false, setUsageInsightsEnabled: (enabled) => set({ usageInsightsEnabled: enabled }), + claudeInteractiveEnabled: false, + setClaudeInteractiveEnabled: (enabled) => + set({ claudeInteractiveEnabled: enabled }), disable1mContext: false, setDisable1mContext: (v) => set({ disable1mContext: v }), alternativeBackendsAvailable: false, diff --git a/src/ui/src/stores/useAppStore.test.ts b/src/ui/src/stores/useAppStore.test.ts index e7ffc2e73..4e2762f03 100644 --- a/src/ui/src/stores/useAppStore.test.ts +++ b/src/ui/src/stores/useAppStore.test.ts @@ -509,6 +509,16 @@ describe("plugin settings routing", () => { }); }); +describe("claudeInteractiveEnabled experimental flag", () => { + it("defaults to false and toggles via setClaudeInteractiveEnabled", () => { + expect(useAppStore.getState().claudeInteractiveEnabled).toBe(false); + useAppStore.getState().setClaudeInteractiveEnabled(true); + expect(useAppStore.getState().claudeInteractiveEnabled).toBe(true); + useAppStore.getState().setClaudeInteractiveEnabled(false); + expect(useAppStore.getState().claudeInteractiveEnabled).toBe(false); + }); +}); + describe("settings overlay counter", () => { beforeEach(() => { useAppStore.setState({ settingsOverlayCount: 0, settingsOpen: false }); From 00b4699482966f3d5fe238e946eb2d7e020c57ed Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 18:52:20 -0700 Subject: [PATCH 04/95] test(settings): reset claudeInteractiveEnabled before each test --- src/ui/src/stores/useAppStore.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/src/stores/useAppStore.test.ts b/src/ui/src/stores/useAppStore.test.ts index 4e2762f03..3df2f2fa5 100644 --- a/src/ui/src/stores/useAppStore.test.ts +++ b/src/ui/src/stores/useAppStore.test.ts @@ -510,6 +510,10 @@ describe("plugin settings routing", () => { }); describe("claudeInteractiveEnabled experimental flag", () => { + beforeEach(() => { + useAppStore.setState({ claudeInteractiveEnabled: false }); + }); + it("defaults to false and toggles via setClaudeInteractiveEnabled", () => { expect(useAppStore.getState().claudeInteractiveEnabled).toBe(false); useAppStore.getState().setClaudeInteractiveEnabled(true); From 0fe7e692664c279b83a57bec88ea49984db32215 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 18:58:51 -0700 Subject: [PATCH 05/95] feat(settings): persist claudeInteractiveEnabled via app_settings Pin the round-trip contract for the new experimental flag through the generic `app_settings` table. No schema or IPC changes were required: `pluginManagementEnabled` (the model this flag follows) is also stored purely as a key-value string and exposed through the existing generic `get_app_setting` / `set_app_setting` Tauri commands. There is no typed Rust `AppSettings` struct, so Steps 4 and 5 of the implementation plan are intentional no-ops. Co-Authored-By: Claude Opus 4.7 --- src/db/settings.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/db/settings.rs b/src/db/settings.rs index e4195d7b5..37305d16d 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -189,6 +189,24 @@ mod tests { assert_eq!(val.as_deref(), Some("value2")); } + /// `claudeInteractiveEnabled` is the Settings → Experimental flag that + /// surfaces the interactive `claude` CLI (tmux/sidecar) agent backend. + /// It is persisted through the same generic `app_settings` table as + /// every other experimental flag (e.g. `pluginManagementEnabled`), so + /// this test just pins the round-trip contract: write a string, read + /// it back unchanged. Pinning by key catches accidental renames before + /// they wipe out a user's saved preference on restart. + #[test] + fn claude_interactive_enabled_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + let v = db.get_app_setting("claudeInteractiveEnabled").unwrap(); + assert_eq!(v.as_deref(), Some("true")); + } + // --- MCP server enabled field --- fn make_mcp_server(id: &str, repo_id: &str, name: &str) -> RepositoryMcpServer { From de941163a2edc7a328edc1cf7def65594a85f3e6 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:03:35 -0700 Subject: [PATCH 06/95] feat(db): add interactive_sessions table migration --- src/db/mod.rs | 30 +++++++++++++++++++ .../20260517020158_interactive_sessions.sql | 16 ++++++++++ src/migrations/mod.rs | 5 ++++ 3 files changed, 51 insertions(+) create mode 100644 src/migrations/20260517020158_interactive_sessions.sql diff --git a/src/db/mod.rs b/src/db/mod.rs index 31dbd01dd..358fede79 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -918,6 +918,36 @@ mod tests { assert!(present); } + #[test] + fn interactive_sessions_table_is_created() { + let db = Database::open_in_memory().unwrap(); + let cols: Vec = db + .conn() + .prepare("SELECT name FROM pragma_table_info('interactive_sessions')") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .filter_map(Result::ok) + .collect(); + for expected in [ + "sid", + "workspace_id", + "host_kind", + "state", + "crash_reason", + "created_at", + "last_attached_at", + "last_screen_blob", + "claude_flags_json", + "pid", + ] { + assert!( + cols.iter().any(|c| c == expected), + "missing column {expected}" + ); + } + } + #[test] fn test_migration_propagates_non_already_exists_errors() { // Real schema mistakes (here: targeting a missing table) must still diff --git a/src/migrations/20260517020158_interactive_sessions.sql b/src/migrations/20260517020158_interactive_sessions.sql new file mode 100644 index 000000000..a784dc356 --- /dev/null +++ b/src/migrations/20260517020158_interactive_sessions.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS interactive_sessions ( + sid TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + host_kind TEXT NOT NULL, + state TEXT NOT NULL, + crash_reason TEXT, + created_at TEXT NOT NULL, + last_attached_at TEXT, + last_screen_blob BLOB, + claude_flags_json TEXT NOT NULL, + pid INTEGER, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_interactive_sessions_workspace + ON interactive_sessions(workspace_id); diff --git a/src/migrations/mod.rs b/src/migrations/mod.rs index 8d6c88dac..43e9d85f8 100644 --- a/src/migrations/mod.rs +++ b/src/migrations/mod.rs @@ -234,6 +234,11 @@ pub const MIGRATIONS: &[Migration] = &[ sql: include_str!("20260513200000_repo_required_inputs.sql"), legacy_version: None, }, + Migration { + id: "20260517020158_interactive_sessions", + sql: include_str!("20260517020158_interactive_sessions.sql"), + legacy_version: None, + }, Migration { id: "20260517190000_agent_scheduled_tasks", sql: include_str!("20260517190000_agent_scheduled_tasks.sql"), From e4c4785c63328ca7c648de89eaff381d2a328fd9 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:09:27 -0700 Subject: [PATCH 07/95] feat(db): add interactive_sessions CRUD helpers --- src/db/interactive_sessions.rs | 224 +++++++++++++++++++++++++++++++++ src/db/mod.rs | 3 + 2 files changed, 227 insertions(+) create mode 100644 src/db/interactive_sessions.rs diff --git a/src/db/interactive_sessions.rs b/src/db/interactive_sessions.rs new file mode 100644 index 000000000..c9807cc64 --- /dev/null +++ b/src/db/interactive_sessions.rs @@ -0,0 +1,224 @@ +//! Interactive (long-lived Claude TUI) session CRUD methods on `Database`. +//! +//! This file contributes a `impl Database { ... }` block to the type defined +//! in `super::Database`. Multiple `impl` blocks on the same type across files +//! are idiomatic Rust; the public method paths resolve identically to a +//! single-block layout. + +use rusqlite::{OptionalExtension, params}; + +use serde::{Deserialize, Serialize}; + +use super::Database; + +/// Persisted row for an interactive Claude session (long-lived TUI host like +/// `tmux` or the in-process sidecar). Mirrors the `interactive_sessions` +/// table 1:1. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InteractiveSessionRow { + pub sid: String, + pub workspace_id: String, + pub host_kind: String, + pub state: String, + pub crash_reason: Option, + pub created_at: String, + pub last_attached_at: Option, + pub last_screen_blob: Option>, + pub claude_flags_json: String, + pub pid: Option, +} + +impl Database { + // --- Interactive Sessions --- + + pub fn create_interactive_session(&self, row: &InteractiveSessionRow) -> rusqlite::Result<()> { + self.conn.execute( + "INSERT INTO interactive_sessions + (sid, workspace_id, host_kind, state, crash_reason, created_at, + last_attached_at, last_screen_blob, claude_flags_json, pid) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + row.sid, + row.workspace_id, + row.host_kind, + row.state, + row.crash_reason, + row.created_at, + row.last_attached_at, + row.last_screen_blob, + row.claude_flags_json, + row.pid, + ], + )?; + Ok(()) + } + + pub fn get_interactive_session( + &self, + sid: &str, + ) -> rusqlite::Result> { + self.conn + .query_row( + "SELECT sid, workspace_id, host_kind, state, crash_reason, created_at, + last_attached_at, last_screen_blob, claude_flags_json, pid + FROM interactive_sessions WHERE sid = ?1", + params![sid], + |r| { + Ok(InteractiveSessionRow { + sid: r.get(0)?, + workspace_id: r.get(1)?, + host_kind: r.get(2)?, + state: r.get(3)?, + crash_reason: r.get(4)?, + created_at: r.get(5)?, + last_attached_at: r.get(6)?, + last_screen_blob: r.get(7)?, + claude_flags_json: r.get(8)?, + pid: r.get(9)?, + }) + }, + ) + .optional() + } + + pub fn list_interactive_sessions_for_workspace( + &self, + workspace_id: &str, + ) -> rusqlite::Result> { + let mut stmt = self.conn.prepare( + "SELECT sid, workspace_id, host_kind, state, crash_reason, created_at, + last_attached_at, last_screen_blob, claude_flags_json, pid + FROM interactive_sessions WHERE workspace_id = ?1 + ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![workspace_id], |r| { + Ok(InteractiveSessionRow { + sid: r.get(0)?, + workspace_id: r.get(1)?, + host_kind: r.get(2)?, + state: r.get(3)?, + crash_reason: r.get(4)?, + created_at: r.get(5)?, + last_attached_at: r.get(6)?, + last_screen_blob: r.get(7)?, + claude_flags_json: r.get(8)?, + pid: r.get(9)?, + }) + })?; + rows.collect() + } + + pub fn set_interactive_session_state( + &self, + sid: &str, + state: &str, + crash_reason: Option<&str>, + ) -> rusqlite::Result<()> { + self.conn.execute( + "UPDATE interactive_sessions SET state = ?1, crash_reason = ?2 WHERE sid = ?3", + params![state, crash_reason, sid], + )?; + Ok(()) + } + + pub fn update_interactive_session_screen( + &self, + sid: &str, + blob: &[u8], + ) -> rusqlite::Result<()> { + self.conn.execute( + "UPDATE interactive_sessions SET last_screen_blob = ?1, + last_attached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE sid = ?2", + params![blob, sid], + )?; + Ok(()) + } + + pub fn delete_interactive_session(&self, sid: &str) -> rusqlite::Result<()> { + self.conn.execute( + "DELETE FROM interactive_sessions WHERE sid = ?1", + params![sid], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::db::test_support::{make_repo, make_workspace}; + + fn setup_db_with_named_workspace(ws_id: &str) -> Database { + let db = Database::open_in_memory().unwrap(); + db.insert_repository(&make_repo("r1", "/tmp/repo1", "repo1")) + .unwrap(); + db.insert_workspace(&make_workspace(ws_id, "r1", "fix-bug")) + .unwrap(); + db + } + + fn make_row(sid: &str, workspace_id: &str) -> InteractiveSessionRow { + InteractiveSessionRow { + sid: sid.into(), + workspace_id: workspace_id.into(), + host_kind: "sidecar".into(), + state: "running".into(), + crash_reason: None, + created_at: "2026-05-16T00:00:00Z".into(), + last_attached_at: None, + last_screen_blob: None, + claude_flags_json: "[]".into(), + pid: Some(1234), + } + } + + #[test] + fn interactive_session_create_get_update_delete() { + let db = setup_db_with_named_workspace("ws-1"); + + let row = make_row("claudette-ws1-aaaaaaaa", "ws-1"); + db.create_interactive_session(&row).unwrap(); + + let got = db + .get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .unwrap(); + assert_eq!(got.state, "running"); + assert_eq!(got.pid, Some(1234)); + + db.set_interactive_session_state("claudette-ws1-aaaaaaaa", "detached", None) + .unwrap(); + let got2 = db + .get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .unwrap(); + assert_eq!(got2.state, "detached"); + + db.update_interactive_session_screen("claudette-ws1-aaaaaaaa", b"\x1b[31mhi\x1b[0m") + .unwrap(); + let got3 = db + .get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .unwrap(); + assert_eq!( + got3.last_screen_blob.as_deref(), + Some(b"\x1b[31mhi\x1b[0m".as_slice()) + ); + // update_interactive_session_screen also stamps last_attached_at. + assert!(got3.last_attached_at.is_some()); + + let listed = db.list_interactive_sessions_for_workspace("ws-1").unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].sid, "claudette-ws1-aaaaaaaa"); + + db.delete_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap(); + assert!( + db.get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .is_none() + ); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 358fede79..b271b322c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -35,6 +35,9 @@ mod chat; mod workspace; pub use workspace::WORKSPACE_ORDER_MODE_PREFIX; +mod interactive_sessions; +pub use interactive_sessions::InteractiveSessionRow; + mod commands; mod usage; From 7653c1c02fea60bbd1d5f1d29a348e629a0b344b Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:13:05 -0700 Subject: [PATCH 08/95] fix(db): error on missing-sid interactive_sessions updates + cover list ordering --- src/db/interactive_sessions.rs | 100 ++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src/db/interactive_sessions.rs b/src/db/interactive_sessions.rs index c9807cc64..d6edea954 100644 --- a/src/db/interactive_sessions.rs +++ b/src/db/interactive_sessions.rs @@ -114,10 +114,13 @@ impl Database { state: &str, crash_reason: Option<&str>, ) -> rusqlite::Result<()> { - self.conn.execute( + let changed = self.conn.execute( "UPDATE interactive_sessions SET state = ?1, crash_reason = ?2 WHERE sid = ?3", params![state, crash_reason, sid], )?; + if changed == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } Ok(()) } @@ -126,12 +129,15 @@ impl Database { sid: &str, blob: &[u8], ) -> rusqlite::Result<()> { - self.conn.execute( + let changed = self.conn.execute( "UPDATE interactive_sessions SET last_screen_blob = ?1, last_attached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE sid = ?2", params![blob, sid], )?; + if changed == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } Ok(()) } @@ -174,6 +180,96 @@ mod tests { } } + fn make_row_with_created_at( + sid: &str, + workspace_id: &str, + created_at: &str, + ) -> InteractiveSessionRow { + InteractiveSessionRow { + sid: sid.into(), + workspace_id: workspace_id.into(), + host_kind: "sidecar".into(), + state: "running".into(), + crash_reason: None, + created_at: created_at.into(), + last_attached_at: None, + last_screen_blob: None, + claude_flags_json: "[]".into(), + pid: Some(1234), + } + } + + #[test] + fn list_interactive_sessions_for_workspace_isolates_and_orders_desc() { + let db = setup_db_with_named_workspace("ws-1"); + // Add a second workspace via the same helper. It re-uses the same + // in-memory DB only when called on the same instance, so insert a + // second workspace directly into the existing DB. + db.insert_workspace(&make_workspace("ws-2", "r1", "other-bug")) + .unwrap(); + + // ws-1 gets two sessions with distinct created_at; the second is + // newer so it should appear first in DESC order. + db.create_interactive_session(&make_row_with_created_at( + "claudette-ws1-older", + "ws-1", + "2026-05-15T10:00:00Z", + )) + .unwrap(); + db.create_interactive_session(&make_row_with_created_at( + "claudette-ws1-newer", + "ws-1", + "2026-05-16T10:00:00Z", + )) + .unwrap(); + + // ws-2 gets a session that must NOT appear in the ws-1 listing. + db.create_interactive_session(&make_row_with_created_at( + "claudette-ws2-only", + "ws-2", + "2026-05-16T12:00:00Z", + )) + .unwrap(); + + let listed = db.list_interactive_sessions_for_workspace("ws-1").unwrap(); + assert_eq!(listed.len(), 2, "should only see ws-1 sessions"); + assert_eq!( + listed[0].sid, "claudette-ws1-newer", + "newer session should sort first (created_at DESC)" + ); + assert_eq!(listed[1].sid, "claudette-ws1-older"); + for row in &listed { + assert_ne!( + row.workspace_id, "ws-2", + "ws-2 sessions must be excluded from ws-1 listing" + ); + } + } + + #[test] + fn set_interactive_session_state_returns_no_rows_when_missing() { + let db = setup_db_with_named_workspace("ws-1"); + let err = db + .set_interactive_session_state("nonexistent-sid", "running", None) + .expect_err("missing sid should error"); + assert!( + matches!(err, rusqlite::Error::QueryReturnedNoRows), + "expected QueryReturnedNoRows, got: {err:?}" + ); + } + + #[test] + fn update_interactive_session_screen_returns_no_rows_when_missing() { + let db = setup_db_with_named_workspace("ws-1"); + let err = db + .update_interactive_session_screen("nonexistent-sid", b"hello") + .expect_err("missing sid should error"); + assert!( + matches!(err, rusqlite::Error::QueryReturnedNoRows), + "expected QueryReturnedNoRows, got: {err:?}" + ); + } + #[test] fn interactive_session_create_get_update_delete() { let db = setup_db_with_named_workspace("ws-1"); From cc49aea3fa42f486e7f77f8dcdf22263ccbec8bf Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:16:12 -0700 Subject: [PATCH 09/95] feat(agent): add interactive_protocol wire types --- src/agent/interactive_protocol.rs | 226 ++++++++++++++++++++++++++++++ src/agent/mod.rs | 1 + 2 files changed, 227 insertions(+) create mode 100644 src/agent/interactive_protocol.rs diff --git a/src/agent/interactive_protocol.rs b/src/agent/interactive_protocol.rs new file mode 100644 index 000000000..a214c03e8 --- /dev/null +++ b/src/agent/interactive_protocol.rs @@ -0,0 +1,226 @@ +//! Wire-protocol types for the `claudette-session-host` sidecar. +//! +//! These types are also re-used by the in-process `SidecarHost` client and the +//! `claudette-session-host` binary, so they live in the library crate so both +//! sides see the same definitions. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Request { + Hello { + protocol_version: u32, + claudette_version: String, + }, + EnsureSession { + sid: String, + spec: SessionSpec, + }, + Attach { + sid: String, + }, + SendInput { + sid: String, + payload: InputPayload, + }, + CaptureScreen { + sid: String, + }, + Resize { + sid: String, + rows: u16, + cols: u16, + }, + Detach { + sid: String, + attach_id: u64, + }, + Stop { + sid: String, + mode: StopMode, + }, + Status, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Response { + HelloAck { + protocol_version: u32, + host_version: String, + pid: u32, + }, + HelloNack { + reason: String, + supported_versions: Vec, + }, + SessionStarted { + sid: String, + pid: u32, + rows: u16, + cols: u16, + }, + AttachStarted { + attach_id: u64, + }, + Ok, + ScreenSnapshot { + rows: u16, + cols: u16, + ansi_bytes_b64: String, + }, + Stopped { + exit_status: i32, + }, + Status { + sessions: Vec, + host_version: String, + }, + Error { + message: String, + recoverable: bool, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Event { + Output { + sid: String, + bytes_b64: String, + seq: u64, + }, + Hook { + sid: String, + hook: HookFired, + }, + Exit { + sid: String, + exit_status: i32, + reason: String, + }, + StreamError { + sid: String, + message: String, + recoverable: bool, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionSpec { + pub working_dir: String, + pub rows: u16, + pub cols: u16, + pub claude_binary: String, + pub claude_args: Vec, + pub env: Vec<(String, String)>, + pub claude_config_dir: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionSummary { + pub sid: String, + pub pid: Option, + pub running: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum InputPayload { + Text { text: String }, + Keys { name: String }, + Bytes { bytes_b64: String }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StopMode { + Graceful, + Force, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HookFired { + Stop, + Awaiting { + reason: Option, + }, + PromptSubmitted, + SubagentStop, + Unknown { + raw_kind: String, + raw_payload: String, + }, +} + +pub const PROTOCOL_VERSION: u32 = 1; + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(value: &T) + where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, + { + let json = serde_json::to_string(value).unwrap(); + let back: T = serde_json::from_str(&json).unwrap(); + assert_eq!(value, &back); + } + + #[test] + fn request_kinds_round_trip() { + roundtrip(&Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: "0.0.0".into(), + }); + roundtrip(&Request::EnsureSession { + sid: "x".into(), + spec: SessionSpec { + working_dir: "/tmp".into(), + rows: 24, + cols: 80, + claude_binary: "/bin/claude".into(), + claude_args: vec!["--model".into(), "opus".into()], + env: vec![("FOO".into(), "BAR".into())], + claude_config_dir: "/tmp/cfg".into(), + }, + }); + roundtrip(&Request::SendInput { + sid: "x".into(), + payload: InputPayload::Text { + text: "hello\r".into(), + }, + }); + roundtrip(&Request::Stop { + sid: "x".into(), + mode: StopMode::Graceful, + }); + } + + #[test] + fn event_kinds_round_trip() { + roundtrip(&Event::Output { + sid: "x".into(), + bytes_b64: "aGk=".into(), + seq: 5, + }); + roundtrip(&Event::Hook { + sid: "x".into(), + hook: HookFired::Awaiting { + reason: Some("blocked on permission".into()), + }, + }); + } + + #[test] + fn hook_unknown_preserves_raw_for_schema_drift() { + let v = HookFired::Unknown { + raw_kind: "FutureHook".into(), + raw_payload: "{\"a\":1}".into(), + }; + roundtrip(&v); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 35016caf3..62d37051d 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -5,6 +5,7 @@ pub mod codex_app_server; mod environment; pub mod harness; pub mod history_seeder; +pub mod interactive_protocol; mod naming; #[cfg(feature = "pi-sdk")] pub mod pi_control; From 1be1cb49cc7eec401ee7605f170b5afd0386b78b Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:20:16 -0700 Subject: [PATCH 10/95] feat(agent): add length-prefixed JSON-line framing --- src/agent/interactive_protocol.rs | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/agent/interactive_protocol.rs b/src/agent/interactive_protocol.rs index a214c03e8..ceb74d332 100644 --- a/src/agent/interactive_protocol.rs +++ b/src/agent/interactive_protocol.rs @@ -157,6 +157,35 @@ pub enum HookFired { pub const PROTOCOL_VERSION: u32 = 1; +pub mod frame { + use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + + pub const MAX_FRAME: usize = 8 * 1024 * 1024; // 8 MB ceiling. + + pub async fn write_frame( + w: &mut W, + payload: &[u8], + ) -> std::io::Result<()> { + let len = + u32::try_from(payload.len()).map_err(|_| std::io::Error::other("frame too large"))?; + w.write_all(&len.to_be_bytes()).await?; + w.write_all(payload).await?; + Ok(()) + } + + pub async fn read_frame(r: &mut R) -> std::io::Result> { + let mut hdr = [0u8; 4]; + r.read_exact(&mut hdr).await?; + let len = u32::from_be_bytes(hdr) as usize; + if len > MAX_FRAME { + return Err(std::io::Error::other("frame too large")); + } + let mut buf = vec![0u8; len]; + r.read_exact(&mut buf).await?; + Ok(buf) + } +} + #[cfg(test)] mod tests { use super::*; @@ -224,3 +253,28 @@ mod tests { roundtrip(&v); } } + +#[cfg(test)] +mod frame_tests { + use super::frame::{read_frame, write_frame}; + use tokio::io::{AsyncWriteExt, duplex}; + + #[tokio::test] + async fn frame_round_trip() { + let (mut a, mut b) = duplex(64 * 1024); + write_frame(&mut a, b"{\"hi\":1}").await.unwrap(); + a.shutdown().await.unwrap(); + let buf = read_frame(&mut b).await.unwrap(); + assert_eq!(buf, b"{\"hi\":1}"); + } + + #[tokio::test] + async fn frame_rejects_oversized() { + let (mut a, mut b) = duplex(64 * 1024); + // 100 MB header — must reject without allocating. + let header = (100u32 * 1024 * 1024).to_be_bytes(); + a.write_all(&header).await.unwrap(); + let err = read_frame(&mut b).await.unwrap_err(); + assert!(err.to_string().contains("frame too large"), "got: {err}"); + } +} From d49b50a892989c9eb568ebc9586db1c1e5610362 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:23:23 -0700 Subject: [PATCH 11/95] test(agent): add stub-tui fixture for interactive host tests --- Cargo.lock | 4 +++ Cargo.toml | 2 +- tests/fixtures/stub-tui/Cargo.toml | 7 +++++ tests/fixtures/stub-tui/src/main.rs | 46 +++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/stub-tui/Cargo.toml create mode 100644 tests/fixtures/stub-tui/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 2da6e8e00..89b72b5a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6221,6 +6221,10 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "stub-tui" +version = "0.0.0" + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 19a3819e4..455e22080 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["src-tauri", "src-server", "src-cli", "src-mobile"] +members = ["src-tauri", "src-server", "src-cli", "src-mobile", "tests/fixtures/stub-tui"] resolver = "2" [package] diff --git a/tests/fixtures/stub-tui/Cargo.toml b/tests/fixtures/stub-tui/Cargo.toml new file mode 100644 index 000000000..4b2a1aa28 --- /dev/null +++ b/tests/fixtures/stub-tui/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "stub-tui" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] diff --git a/tests/fixtures/stub-tui/src/main.rs b/tests/fixtures/stub-tui/src/main.rs new file mode 100644 index 000000000..0d88c93c2 --- /dev/null +++ b/tests/fixtures/stub-tui/src/main.rs @@ -0,0 +1,46 @@ +//! Stub TUI used by interactive_host integration tests. +//! +//! Behavior: +//! - Prints `READY\n` on startup so tests can synchronize. +//! - Reads stdin line-by-line. Each line is echoed back as `OUT: \n`. +//! - If `STUB_TUI_FAKE_AWAITING_AFTER` is set to a positive integer N, after +//! echoing N lines we exit 0 (simulating `Stop` hook) without further output. +//! - If `STUB_TUI_CRASH_AFTER` is set, panic after that many lines. +//! - Line `quit\n` exits 0 immediately. + +use std::io::{BufRead, Write}; + +fn main() { + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "READY").unwrap(); + stdout.flush().unwrap(); + + let limit: Option = std::env::var("STUB_TUI_FAKE_AWAITING_AFTER") + .ok() + .and_then(|s| s.parse().ok()); + let crash_after: Option = std::env::var("STUB_TUI_CRASH_AFTER") + .ok() + .and_then(|s| s.parse().ok()); + + let stdin = std::io::stdin(); + let mut count: u32 = 0; + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + if line == "quit" { + return; + } + writeln!(stdout, "OUT: {line}").unwrap(); + stdout.flush().unwrap(); + count += 1; + if let Some(n) = limit + && count >= n + { + return; + } + if let Some(n) = crash_after + && count >= n + { + panic!("stub-tui crashing as instructed"); + } + } +} From b3c01c23581b29d2e959fdba631f0f8e2b3239ab Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:27:59 -0700 Subject: [PATCH 12/95] feat(agent): define InteractiveHost trait and shared types --- Cargo.lock | 58 ++++++------------- Cargo.toml | 8 +++ src/agent/interactive_host/availability.rs | 1 + src/agent/interactive_host/conformance.rs | 1 + src/agent/interactive_host/mod.rs | 50 ++++++++++++++++ src/agent/interactive_host/sidecar.rs | 1 + src/agent/interactive_host/tmux.rs | 1 + src/agent/interactive_host/types.rs | 66 ++++++++++++++++++++++ src/agent/mod.rs | 5 ++ 9 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 src/agent/interactive_host/availability.rs create mode 100644 src/agent/interactive_host/conformance.rs create mode 100644 src/agent/interactive_host/mod.rs create mode 100644 src/agent/interactive_host/sidecar.rs create mode 100644 src/agent/interactive_host/tmux.rs create mode 100644 src/agent/interactive_host/types.rs diff --git a/Cargo.lock b/Cargo.lock index 89b72b5a1..8a4f702f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -850,7 +850,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "claudette" -version = "0.25.0" +version = "0.24.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -858,8 +858,6 @@ dependencies = [ "dirs", "flate2", "futures", - "futures-util", - "hex", "interprocess", "libc", "minisign-verify", @@ -867,17 +865,16 @@ dependencies = [ "notify", "open", "rand 0.8.5", - "reqwest", "rodio", "rusqlite", - "rustls", "serde", "serde_json", "sha2", "tar", "tempfile", + "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-stream", "tracing", "tracing-appender", "tracing-subscriber", @@ -890,7 +887,7 @@ dependencies = [ [[package]] name = "claudette-cli" -version = "0.25.0" +version = "0.24.0" dependencies = [ "clap", "clap_complete", @@ -906,27 +903,9 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "claudette-mobile" -version = "0.25.0" -dependencies = [ - "chrono", - "claudette", - "gethostname", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-barcode-scanner", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "claudette-server" -version = "0.25.0" +version = "0.24.0" dependencies = [ "base64 0.22.1", "clap", @@ -955,7 +934,7 @@ dependencies = [ [[package]] name = "claudette-tauri" -version = "0.25.0" +version = "0.24.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -6599,20 +6578,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "tauri-plugin-barcode-scanner" -version = "2.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "485cbcf227f04117e930be748ea71d835900466dcd1d455d5ec284d36107a305" -dependencies = [ - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", -] - [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.2" @@ -7077,6 +7042,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.26.2" diff --git a/Cargo.toml b/Cargo.toml index 455e22080..0ce9f33a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,14 @@ chrono = "0.4" # build platform-agnostic; `json` enables `Response::json` for the # small response bodies these endpoints return. reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "stream"] } +# Provides the `Stream` trait re-export used by `AttachStream` in +# `src/agent/interactive_host/`. Lightweight wrapper crate already on the +# tokio side of the workspace dep graph. +tokio-stream = "0.1" +# `#[derive(thiserror::Error)]` for the `HostError` enum exposed by the +# `InteractiveHost` trait. v2 matches the version unified across the +# transitive workspace tree. +thiserror = "2" [target.'cfg(windows)'.dependencies] # Read fresh PATH from HKCU + HKLM Environment registry keys so we pick up diff --git a/src/agent/interactive_host/availability.rs b/src/agent/interactive_host/availability.rs new file mode 100644 index 000000000..47a073325 --- /dev/null +++ b/src/agent/interactive_host/availability.rs @@ -0,0 +1 @@ +//! Placeholder — implementation lands in Task C6. diff --git a/src/agent/interactive_host/conformance.rs b/src/agent/interactive_host/conformance.rs new file mode 100644 index 000000000..13eb3f6d9 --- /dev/null +++ b/src/agent/interactive_host/conformance.rs @@ -0,0 +1 @@ +//! Placeholder — implementation lands in Task D1. diff --git a/src/agent/interactive_host/mod.rs b/src/agent/interactive_host/mod.rs new file mode 100644 index 000000000..8e72b799b --- /dev/null +++ b/src/agent/interactive_host/mod.rs @@ -0,0 +1,50 @@ +//! Detachable host abstraction for interactive `claude` sessions. + +pub mod availability; +pub mod conformance; +pub mod sidecar; +#[cfg(unix)] +pub mod tmux; +pub mod types; + +pub use crate::agent::interactive_protocol::{HookFired, InputPayload, SessionSpec, StopMode}; +pub use types::{ + AttachEvent, AttachId, HostHandle, HostSessionSummary, HostStatus, ScreenSnapshot, SessionId, +}; + +use async_trait::async_trait; +use std::pin::Pin; +use tokio_stream::Stream; + +/// Type alias for the live attach stream. +pub type AttachStream = Pin + Send + 'static>>; + +#[async_trait] +pub trait InteractiveHost: Send + Sync { + async fn ensure_session( + &self, + sid: &SessionId, + spec: &SessionSpec, + ) -> Result; + async fn attach(&self, sid: &SessionId) -> Result<(AttachId, AttachStream), HostError>; + async fn send_input(&self, sid: &SessionId, payload: InputPayload) -> Result<(), HostError>; + async fn capture_screen(&self, sid: &SessionId) -> Result; + async fn resize(&self, sid: &SessionId, rows: u16, cols: u16) -> Result<(), HostError>; + async fn detach(&self, sid: &SessionId, attach_id: AttachId) -> Result<(), HostError>; + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError>; + async fn status(&self) -> Result; +} + +#[derive(Debug, thiserror::Error)] +pub enum HostError { + #[error("session not found: {0}")] + NotFound(String), + #[error("host unavailable: {0}")] + Unavailable(String), + #[error("protocol error: {0}")] + Protocol(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("other: {0}")] + Other(String), +} diff --git a/src/agent/interactive_host/sidecar.rs b/src/agent/interactive_host/sidecar.rs new file mode 100644 index 000000000..df215132d --- /dev/null +++ b/src/agent/interactive_host/sidecar.rs @@ -0,0 +1 @@ +//! Placeholder — implementation lands in Task B6. diff --git a/src/agent/interactive_host/tmux.rs b/src/agent/interactive_host/tmux.rs new file mode 100644 index 000000000..42c2ca41f --- /dev/null +++ b/src/agent/interactive_host/tmux.rs @@ -0,0 +1 @@ +//! Placeholder — implementation lands in Task B5. diff --git a/src/agent/interactive_host/types.rs b/src/agent/interactive_host/types.rs new file mode 100644 index 000000000..43267b28f --- /dev/null +++ b/src/agent/interactive_host/types.rs @@ -0,0 +1,66 @@ +//! Shared types used by both InteractiveHost implementations. + +use serde::{Deserialize, Serialize}; + +pub use crate::agent::interactive_protocol::{HookFired, InputPayload, SessionSpec, StopMode}; + +/// Stable identifier for an interactive session. +/// +/// Format: `claudette--`. Identical between tmux and +/// sidecar so a single string identifies the session in any host. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SessionId(pub String); + +impl SessionId { + pub fn new(workspace_short: &str, sid8: &str) -> Self { + Self(format!("claudette-{workspace_short}-{sid8}")) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Returned from `ensure_session`. +#[derive(Debug, Clone)] +pub struct HostHandle { + pub sid: SessionId, + pub pid: Option, + pub rows: u16, + pub cols: u16, +} + +/// Identifies a single attach subscription. Multiple attaches per session are +/// allowed; detach uses the attach_id to drop the right one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AttachId(pub u64); + +/// Event yielded by an `AttachStream`. +#[derive(Debug, Clone)] +pub enum AttachEvent { + Output { bytes: Vec, seq: u64 }, + Hook(HookFired), + Exit { exit_status: i32, reason: String }, + Error { message: String, recoverable: bool }, +} + +/// Snapshot of the current screen for instant repaint on reattach. +#[derive(Debug, Clone)] +pub struct ScreenSnapshot { + pub rows: u16, + pub cols: u16, + pub ansi_bytes: Vec, +} + +/// Host enumeration entry. +#[derive(Debug, Clone)] +pub struct HostSessionSummary { + pub sid: SessionId, + pub pid: Option, + pub running: bool, +} + +#[derive(Debug, Clone)] +pub struct HostStatus { + pub host_version: String, + pub sessions: Vec, +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 62d37051d..dc284ed4b 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -5,6 +5,7 @@ pub mod codex_app_server; mod environment; pub mod harness; pub mod history_seeder; +pub mod interactive_host; pub mod interactive_protocol; mod naming; #[cfg(feature = "pi-sdk")] @@ -34,6 +35,10 @@ pub use harness::{ AgentHarnessCapabilities, AgentHarnessKind, AgentSession, ClaudeCodeHarness, PersistentSessionStart, }; +pub use interactive_host::{ + AttachEvent, AttachId, AttachStream, HostError, HostHandle, HostStatus, InteractiveHost, + ScreenSnapshot, SessionId, +}; pub use naming::{ generate_branch_name, generate_session_name, persist_claude_custom_title, sanitize_branch_name, }; From 56764687e8095f17503cdee563857193db420253 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:32:34 -0700 Subject: [PATCH 13/95] fix(agent): re-export HostSessionSummary; clean up interactive_host placeholders --- src/agent/interactive_host/availability.rs | 2 +- src/agent/interactive_host/conformance.rs | 2 +- src/agent/interactive_host/sidecar.rs | 2 +- src/agent/interactive_host/tmux.rs | 2 +- src/agent/interactive_host/types.rs | 2 +- src/agent/mod.rs | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/agent/interactive_host/availability.rs b/src/agent/interactive_host/availability.rs index 47a073325..df215132d 100644 --- a/src/agent/interactive_host/availability.rs +++ b/src/agent/interactive_host/availability.rs @@ -1 +1 @@ -//! Placeholder — implementation lands in Task C6. +//! Placeholder — implementation lands in Task B6. diff --git a/src/agent/interactive_host/conformance.rs b/src/agent/interactive_host/conformance.rs index 13eb3f6d9..42c2ca41f 100644 --- a/src/agent/interactive_host/conformance.rs +++ b/src/agent/interactive_host/conformance.rs @@ -1 +1 @@ -//! Placeholder — implementation lands in Task D1. +//! Placeholder — implementation lands in Task B5. diff --git a/src/agent/interactive_host/sidecar.rs b/src/agent/interactive_host/sidecar.rs index df215132d..47a073325 100644 --- a/src/agent/interactive_host/sidecar.rs +++ b/src/agent/interactive_host/sidecar.rs @@ -1 +1 @@ -//! Placeholder — implementation lands in Task B6. +//! Placeholder — implementation lands in Task C6. diff --git a/src/agent/interactive_host/tmux.rs b/src/agent/interactive_host/tmux.rs index 42c2ca41f..13eb3f6d9 100644 --- a/src/agent/interactive_host/tmux.rs +++ b/src/agent/interactive_host/tmux.rs @@ -1 +1 @@ -//! Placeholder — implementation lands in Task B5. +//! Placeholder — implementation lands in Task D1. diff --git a/src/agent/interactive_host/types.rs b/src/agent/interactive_host/types.rs index 43267b28f..4618a6605 100644 --- a/src/agent/interactive_host/types.rs +++ b/src/agent/interactive_host/types.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -pub use crate::agent::interactive_protocol::{HookFired, InputPayload, SessionSpec, StopMode}; +use crate::agent::interactive_protocol::HookFired; /// Stable identifier for an interactive session. /// diff --git a/src/agent/mod.rs b/src/agent/mod.rs index dc284ed4b..ba1fb5dce 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -36,8 +36,8 @@ pub use harness::{ PersistentSessionStart, }; pub use interactive_host::{ - AttachEvent, AttachId, AttachStream, HostError, HostHandle, HostStatus, InteractiveHost, - ScreenSnapshot, SessionId, + AttachEvent, AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, }; pub use naming::{ generate_branch_name, generate_session_name, persist_claude_custom_title, sanitize_branch_name, From 0d9e798a5cdd61d9850c0a267e3198f58251f247 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:35:56 -0700 Subject: [PATCH 14/95] test(agent): add InteractiveHost conformance suite skeleton --- src/agent/interactive_host/conformance.rs | 160 +++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/src/agent/interactive_host/conformance.rs b/src/agent/interactive_host/conformance.rs index 42c2ca41f..d9d29c1ed 100644 --- a/src/agent/interactive_host/conformance.rs +++ b/src/agent/interactive_host/conformance.rs @@ -1 +1,159 @@ -//! Placeholder — implementation lands in Task B5. +//! Conformance suite both InteractiveHost impls must pass. +//! +//! Tests build a host, point it at a stub TUI, and exercise the full lifecycle. +//! Both impls share the same expectations. + +use super::{ + AttachEvent, HostStatus, InputPayload, InteractiveHost, ScreenSnapshot, SessionId, SessionSpec, + StopMode, +}; +use futures::StreamExt; +use std::time::Duration; +use tokio::time::timeout; + +pub struct ConformanceFixture { + pub spec: SessionSpec, + pub sid: SessionId, +} + +/// Run the full conformance suite against `host`. +pub async fn run(host: &H, fx: &ConformanceFixture) { + ensure_session_is_idempotent(host, fx).await; + send_then_capture_returns_bytes(host, fx).await; + multiple_attaches_each_receive_events(host, fx).await; + detach_does_not_kill_session(host, fx).await; + stop_graceful_yields_exit_event(host, fx).await; + status_lists_only_running_sessions(host, fx).await; +} + +async fn ensure_session_is_idempotent(host: &H, fx: &ConformanceFixture) { + let h1 = host + .ensure_session(&fx.sid, &fx.spec) + .await + .expect("ensure 1"); + let h2 = host + .ensure_session(&fx.sid, &fx.spec) + .await + .expect("ensure 2"); + assert_eq!(h1.sid, h2.sid); +} + +async fn send_then_capture_returns_bytes(host: &H, fx: &ConformanceFixture) { + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + let (_attach_id, mut stream) = host.attach(&fx.sid).await.unwrap(); + host.send_input( + &fx.sid, + InputPayload::Text { + text: "hello\n".into(), + }, + ) + .await + .unwrap(); + let mut got = Vec::::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + while tokio::time::Instant::now() < deadline { + match timeout(Duration::from_millis(200), stream.next()).await { + Ok(Some(AttachEvent::Output { bytes, .. })) => got.extend_from_slice(&bytes), + Ok(Some(_)) => {} + Ok(None) => break, + Err(_) => {} + } + if String::from_utf8_lossy(&got).contains("OUT: hello") { + break; + } + } + assert!( + String::from_utf8_lossy(&got).contains("OUT: hello"), + "did not see echoed line in stream: {:?}", + String::from_utf8_lossy(&got) + ); + let snap: ScreenSnapshot = host.capture_screen(&fx.sid).await.unwrap(); + assert!(snap.rows >= 1 && snap.cols >= 1); +} + +async fn multiple_attaches_each_receive_events( + host: &H, + fx: &ConformanceFixture, +) { + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + let (_a1, mut s1) = host.attach(&fx.sid).await.unwrap(); + let (_a2, mut s2) = host.attach(&fx.sid).await.unwrap(); + host.send_input( + &fx.sid, + InputPayload::Text { + text: "ping\n".into(), + }, + ) + .await + .unwrap(); + let s1_seen = drain_until_contains(&mut s1, "OUT: ping", Duration::from_secs(3)).await; + let s2_seen = drain_until_contains(&mut s2, "OUT: ping", Duration::from_secs(3)).await; + assert!(s1_seen, "first attach missed ping"); + assert!(s2_seen, "second attach missed ping"); +} + +async fn detach_does_not_kill_session(host: &H, fx: &ConformanceFixture) { + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + let (attach_id, _stream) = host.attach(&fx.sid).await.unwrap(); + host.detach(&fx.sid, attach_id).await.unwrap(); + // Session must still be enumerable as running. + let st = host.status().await.unwrap(); + assert!( + st.sessions.iter().any(|s| s.sid == fx.sid && s.running), + "session vanished after detach" + ); +} + +async fn stop_graceful_yields_exit_event(host: &H, fx: &ConformanceFixture) { + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + let (_attach_id, mut stream) = host.attach(&fx.sid).await.unwrap(); + host.stop(&fx.sid, StopMode::Graceful).await.unwrap(); + let mut got_exit = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + match timeout(Duration::from_millis(200), stream.next()).await { + Ok(Some(AttachEvent::Exit { .. })) => { + got_exit = true; + break; + } + Ok(Some(_)) => {} + Ok(None) => break, + Err(_) => {} + } + } + assert!(got_exit, "no exit event after stop"); +} + +async fn status_lists_only_running_sessions( + host: &H, + _fx: &ConformanceFixture, +) { + let st: HostStatus = host.status().await.unwrap(); + // After stop in the previous test, our session should be gone. + assert!( + !st.sessions + .iter() + .any(|s| s.running && s.sid.as_str().contains("claudette-")) + ); +} + +async fn drain_until_contains(stream: &mut S, needle: &str, total: Duration) -> bool +where + S: futures::Stream + Unpin, +{ + use futures::StreamExt; + let mut buf = Vec::::new(); + let deadline = tokio::time::Instant::now() + total; + while tokio::time::Instant::now() < deadline { + match timeout(Duration::from_millis(200), stream.next()).await { + Ok(Some(AttachEvent::Output { bytes, .. })) => buf.extend_from_slice(&bytes), + Ok(Some(_)) => {} + Ok(None) => return String::from_utf8_lossy(&buf).contains(needle), + Err(_) => {} + } + if String::from_utf8_lossy(&buf).contains(needle) { + return true; + } + } + String::from_utf8_lossy(&buf).contains(needle) +} From 120ddf37cca4879b181d510968beb6383eb709e6 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:38:43 -0700 Subject: [PATCH 15/95] fix(agent): make conformance status subtest self-contained; drop dupe import --- src/agent/interactive_host/conformance.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/agent/interactive_host/conformance.rs b/src/agent/interactive_host/conformance.rs index d9d29c1ed..8a102a12b 100644 --- a/src/agent/interactive_host/conformance.rs +++ b/src/agent/interactive_host/conformance.rs @@ -124,16 +124,18 @@ async fn stop_graceful_yields_exit_event(host: &H, fx: &Conf assert!(got_exit, "no exit event after stop"); } -async fn status_lists_only_running_sessions( - host: &H, - _fx: &ConformanceFixture, -) { +async fn status_lists_only_running_sessions(host: &H, fx: &ConformanceFixture) { + // Independently: spawn a session, stop it, and confirm the host no longer + // reports it as running. Doesn't rely on any prior subtest's side-effects. + let _ = host.ensure_session(&fx.sid, &fx.spec).await.unwrap(); + host.stop(&fx.sid, StopMode::Force).await.unwrap(); + // Give the host a moment to reap. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; let st: HostStatus = host.status().await.unwrap(); - // After stop in the previous test, our session should be gone. assert!( - !st.sessions - .iter() - .any(|s| s.running && s.sid.as_str().contains("claudette-")) + !st.sessions.iter().any(|s| s.sid == fx.sid && s.running), + "session {:?} still listed as running after stop", + fx.sid ); } @@ -141,7 +143,6 @@ async fn drain_until_contains(stream: &mut S, needle: &str, total: Duration) where S: futures::Stream + Unpin, { - use futures::StreamExt; let mut buf = Vec::::new(); let deadline = tokio::time::Instant::now() + total; while tokio::time::Instant::now() < deadline { From 052e5a4a1eb2b329bc2a8c00d1885e2668b91c30 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:41:23 -0700 Subject: [PATCH 16/95] feat(agent): add tmux availability check with TTL cache --- src/agent/interactive_host/availability.rs | 120 ++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/src/agent/interactive_host/availability.rs b/src/agent/interactive_host/availability.rs index df215132d..2d26803cb 100644 --- a/src/agent/interactive_host/availability.rs +++ b/src/agent/interactive_host/availability.rs @@ -1 +1,119 @@ -//! Placeholder — implementation lands in Task B6. +//! Availability checks for the supported interactive hosts. +//! +//! These checks cache their result for 30 seconds to avoid shelling out on +//! every operation. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TmuxAvailability { + /// tmux >= 3.0 found on PATH. + Available { version: String }, + /// tmux found but too old. + TooOld { version: String, minimum: String }, + /// tmux not on PATH. + NotFound, +} + +const TTL: Duration = Duration::from_secs(30); + +struct CachedTmux { + at: Instant, + value: TmuxAvailability, +} + +static TMUX_CACHE: Mutex> = Mutex::new(None); + +/// Returns the cached `tmux` availability, refreshing it if the cached value is +/// older than the 30-second TTL. +/// +/// # Panics +/// +/// Panics if the internal cache mutex has been poisoned by a previous panic. +pub async fn check_tmux() -> TmuxAvailability { + { + let g = TMUX_CACHE.lock().expect("poisoned"); + if let Some(c) = g.as_ref() + && c.at.elapsed() < TTL + { + return c.value.clone(); + } + } + let v = check_tmux_uncached().await; + let mut g = TMUX_CACHE.lock().expect("poisoned"); + *g = Some(CachedTmux { + at: Instant::now(), + value: v.clone(), + }); + v +} + +async fn check_tmux_uncached() -> TmuxAvailability { + let out = tokio::process::Command::new("tmux") + .arg("-V") + .output() + .await; + let Ok(out) = out else { + return TmuxAvailability::NotFound; + }; + if !out.status.success() { + return TmuxAvailability::NotFound; + } + let s = String::from_utf8_lossy(&out.stdout); + // tmux -V prints e.g. "tmux 3.4" + let ver = s.split_whitespace().nth(1).unwrap_or("").to_string(); + if version_at_least(&ver, 3, 0) { + TmuxAvailability::Available { version: ver } + } else { + TmuxAvailability::TooOld { + version: ver, + minimum: "3.0".into(), + } + } +} + +fn version_at_least(ver: &str, want_major: u32, want_minor: u32) -> bool { + let mut parts = ver.split('.'); + let major = parts + .next() + .and_then(|p| p.parse::().ok()) + .unwrap_or(0); + let minor = parts + .next() + .and_then(|p| { + p.trim_end_matches(|c: char| !c.is_ascii_digit()) + .parse::() + .ok() + }) + .unwrap_or(0); + (major, minor) >= (want_major, want_minor) +} + +/// Clear the tmux cache (test-only helper). +#[cfg(test)] +pub fn clear_tmux_cache_for_test() { + *TMUX_CACHE.lock().unwrap() = None; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_at_least_examples() { + assert!(version_at_least("3.4", 3, 0)); + assert!(version_at_least("3.0", 3, 0)); + assert!(!version_at_least("2.9", 3, 0)); + assert!(!version_at_least("", 3, 0)); + assert!(version_at_least("3.4a", 3, 0)); + } + + #[tokio::test] + async fn check_tmux_cache_is_stable_within_ttl() { + clear_tmux_cache_for_test(); + let a = check_tmux().await; + let b = check_tmux().await; + assert_eq!(a, b); + } +} From df1c97f93086ce55af3ff2e4cfc6e3b48ed4d229 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:43:56 -0700 Subject: [PATCH 17/95] fix(agent): robust tmux version parsing for pre-release suffixes --- src/agent/interactive_host/availability.rs | 24 ++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/agent/interactive_host/availability.rs b/src/agent/interactive_host/availability.rs index 2d26803cb..0f9993f5b 100644 --- a/src/agent/interactive_host/availability.rs +++ b/src/agent/interactive_host/availability.rs @@ -74,26 +74,23 @@ async fn check_tmux_uncached() -> TmuxAvailability { } fn version_at_least(ver: &str, want_major: u32, want_minor: u32) -> bool { + fn leading_u32(s: &str) -> u32 { + s.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0) + } let mut parts = ver.split('.'); - let major = parts - .next() - .and_then(|p| p.parse::().ok()) - .unwrap_or(0); - let minor = parts - .next() - .and_then(|p| { - p.trim_end_matches(|c: char| !c.is_ascii_digit()) - .parse::() - .ok() - }) - .unwrap_or(0); + let major = leading_u32(parts.next().unwrap_or("")); + let minor = leading_u32(parts.next().unwrap_or("")); (major, minor) >= (want_major, want_minor) } /// Clear the tmux cache (test-only helper). #[cfg(test)] pub fn clear_tmux_cache_for_test() { - *TMUX_CACHE.lock().unwrap() = None; + *TMUX_CACHE.lock().expect("poisoned") = None; } #[cfg(test)] @@ -107,6 +104,7 @@ mod tests { assert!(!version_at_least("2.9", 3, 0)); assert!(!version_at_least("", 3, 0)); assert!(version_at_least("3.4a", 3, 0)); + assert!(version_at_least("3.4-rc1", 3, 0)); } #[tokio::test] From ed918f0f6ce4096365ae9c86ab7703036c3d5c62 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:46:41 -0700 Subject: [PATCH 18/95] feat(session-host): scaffold claudette-session-host crate --- Cargo.lock | 111 +++++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- src-session-host/Cargo.toml | 44 ++++++++++++++ src-session-host/src/main.rs | 15 +++++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src-session-host/Cargo.toml create mode 100644 src-session-host/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 8a4f702f7..a5fba120c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.11.0", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling 3.11.0", + "regex-automata", + "rustix", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -932,6 +957,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "claudette-session-host" +version = "0.24.0" +dependencies = [ + "alacritty_terminal", + "async-trait", + "base64 0.22.1", + "claudette", + "futures", + "interprocess", + "portable-pty", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", +] + [[package]] name = "claudette-tauri" version = "0.24.0" @@ -1290,6 +1336,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "darling" version = "0.20.11" @@ -2740,6 +2792,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hound" version = "3.5.1" @@ -3701,6 +3762,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "mlua" version = "0.10.5" @@ -5440,6 +5510,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix", +] + [[package]] name = "rustls" version = "0.23.37" @@ -6004,6 +6085,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -7521,6 +7612,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -7661,6 +7758,20 @@ dependencies = [ "libc", ] +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.11.0", + "cursor-icon", + "log", + "memchr", + "serde", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 0ce9f33a8..fc1085fc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["src-tauri", "src-server", "src-cli", "src-mobile", "tests/fixtures/stub-tui"] +members = ["src-tauri", "src-server", "src-cli", "src-mobile", "src-session-host", "tests/fixtures/stub-tui"] resolver = "2" [package] diff --git a/src-session-host/Cargo.toml b/src-session-host/Cargo.toml new file mode 100644 index 000000000..a8b88b077 --- /dev/null +++ b/src-session-host/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "claudette-session-host" +version = "0.24.0" +edition = "2024" +license = "MIT" +description = "Long-lived sidecar that owns interactive Claude PTYs for Claudette" + +[[bin]] +name = "claudette-session-host" +path = "src/main.rs" + +[dependencies] +# `default-features = false` so transitive feature unification doesn't +# pull `pi-sdk` back into the lib when the Tauri binary opts out. This +# crate consumes the interactive-host protocol types from `claudette` +# but has no Pi-specific surface. +claudette = { path = "..", default-features = false } +tokio = { version = "1", features = ["full"] } +# Cross-platform local IPC: Unix domain sockets on macOS/Linux, Named Pipes +# on Windows. One async API for both — same shape used by `agent_mcp::bridge` +# in the core crate. +interprocess = { version = "2", features = ["tokio"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Structured, span-aware logging. Pinned to the same versions used across +# the workspace so the session host emits events through the same registry +# when run standalone or embedded in tests. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +# PTY ownership for interactive Claude processes. Matches the pin used in +# `src-tauri` and `src-server`. +portable-pty = "0.8" +# In-memory terminal grid model for `capture_screen` snapshots. Not yet a +# workspace dep elsewhere — pinned to the latest stable major at the time +# the session-host crate was scaffolded. +alacritty_terminal = "0.26" +base64 = "0.22" +async-trait = "0.1" +thiserror = "2" +futures = "0.3" +tokio-stream = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/src-session-host/src/main.rs b/src-session-host/src/main.rs new file mode 100644 index 000000000..6b763adf4 --- /dev/null +++ b/src-session-host/src/main.rs @@ -0,0 +1,15 @@ +//! The Claudette interactive-Claude session host. +//! +//! Long-lived sidecar process. Owns claude PTYs. Exposes a JSON-line local +//! socket protocol (Unix-domain socket / Named Pipe). See +//! `claudette::agent::interactive_protocol`. + +fn main() -> std::io::Result<()> { + tracing_subscriber::fmt().with_env_filter("info").init(); + tracing::info!( + "claudette-session-host {} starting", + env!("CARGO_PKG_VERSION") + ); + // Stub: just exit. Real server logic comes in Task C2+. + Ok(()) +} From 3d69f0fc8dec868bcfd7d18a3e35fb97e0d587e6 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:49:11 -0700 Subject: [PATCH 19/95] fix(session-host): honor RUST_LOG env var for tracing --- src-session-host/src/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src-session-host/src/main.rs b/src-session-host/src/main.rs index 6b763adf4..5545eb9d6 100644 --- a/src-session-host/src/main.rs +++ b/src-session-host/src/main.rs @@ -5,7 +5,12 @@ //! `claudette::agent::interactive_protocol`. fn main() -> std::io::Result<()> { - tracing_subscriber::fmt().with_env_filter("info").init(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); tracing::info!( "claudette-session-host {} starting", env!("CARGO_PKG_VERSION") From 1e5e9c5b8158d939fda2f346fe6f34e91627926e Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:55:11 -0700 Subject: [PATCH 20/95] feat(session-host): accept connections and answer Hello handshake --- Cargo.lock | 18 ++++ src-session-host/Cargo.toml | 7 ++ src-session-host/src/idle.rs | 3 + src-session-host/src/lib.rs | 10 +++ src-session-host/src/main.rs | 16 ++-- src-session-host/src/server.rs | 130 ++++++++++++++++++++++++++++ src-session-host/src/session.rs | 3 + src-session-host/tests/handshake.rs | 58 +++++++++++++ 8 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 src-session-host/src/idle.rs create mode 100644 src-session-host/src/lib.rs create mode 100644 src-session-host/src/server.rs create mode 100644 src-session-host/src/session.rs create mode 100644 src-session-host/tests/handshake.rs diff --git a/Cargo.lock b/Cargo.lock index a5fba120c..287034a0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,6 +976,7 @@ dependencies = [ "tokio-stream", "tracing", "tracing-subscriber", + "whoami", ] [[package]] @@ -7821,6 +7822,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -8150,6 +8157,17 @@ dependencies = [ "winsafe", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/src-session-host/Cargo.toml b/src-session-host/Cargo.toml index a8b88b077..cee08bb94 100644 --- a/src-session-host/Cargo.toml +++ b/src-session-host/Cargo.toml @@ -5,6 +5,10 @@ edition = "2024" license = "MIT" description = "Long-lived sidecar that owns interactive Claude PTYs for Claudette" +[lib] +name = "claudette_session_host" +path = "src/lib.rs" + [[bin]] name = "claudette-session-host" path = "src/main.rs" @@ -39,6 +43,9 @@ async-trait = "0.1" thiserror = "2" futures = "0.3" tokio-stream = "0.1" +# Per-user socket path scheme picks up the current username so concurrent +# users on the same host don't collide on `$TMPDIR/claudette-session-host/`. +whoami = "1" [dev-dependencies] tempfile = "3" diff --git a/src-session-host/src/idle.rs b/src-session-host/src/idle.rs new file mode 100644 index 000000000..3889f73cb --- /dev/null +++ b/src-session-host/src/idle.rs @@ -0,0 +1,3 @@ +//! Idle-shutdown timer for the session host. +//! +//! Placeholder — populated in Task C5 (idle-exit policy). diff --git a/src-session-host/src/lib.rs b/src-session-host/src/lib.rs new file mode 100644 index 000000000..53d019e93 --- /dev/null +++ b/src-session-host/src/lib.rs @@ -0,0 +1,10 @@ +//! Library surface of the Claudette session host. +//! +//! The session-host binary owns interactive Claude PTYs and exposes a local +//! socket protocol so the Tauri app (and integration tests) can talk to it +//! out of process. Splitting the crate into a `lib` + `bin` lets tests link +//! against the server directly without spawning the binary. + +pub mod idle; +pub mod server; +pub mod session; diff --git a/src-session-host/src/main.rs b/src-session-host/src/main.rs index 5545eb9d6..5857426f8 100644 --- a/src-session-host/src/main.rs +++ b/src-session-host/src/main.rs @@ -4,17 +4,23 @@ //! socket protocol (Unix-domain socket / Named Pipe). See //! `claudette::agent::interactive_protocol`. -fn main() -> std::io::Result<()> { +use claudette_session_host::server; + +#[tokio::main] +async fn main() -> std::io::Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); + + let socket_path = server::default_socket_path(); tracing::info!( - "claudette-session-host {} starting", - env!("CARGO_PKG_VERSION") + version = env!("CARGO_PKG_VERSION"), + socket = %socket_path.display(), + "claudette-session-host starting" ); - // Stub: just exit. Real server logic comes in Task C2+. - Ok(()) + + server::run_at(&socket_path).await } diff --git a/src-session-host/src/server.rs b/src-session-host/src/server.rs new file mode 100644 index 000000000..bed918335 --- /dev/null +++ b/src-session-host/src/server.rs @@ -0,0 +1,130 @@ +//! Local-socket server for the session host. +//! +//! Listens on a per-user path: +//! Unix: `$TMPDIR/claudette-session-host/.sock` +//! Windows: `\\.\pipe\claudette-session-host-` +//! +//! Each accepted connection runs in its own task. The first frame must be a +//! `Request::Hello`; the server replies with either `Response::HelloAck` or +//! `Response::HelloNack` depending on protocol_version compatibility. +//! +//! Anything past the handshake is the responsibility of later tasks +//! (C3 — session ops, C4 — event streaming, etc.). + +use std::path::{Path, PathBuf}; + +use claudette::agent::interactive_protocol::{ + PROTOCOL_VERSION, Request, Response, + frame::{read_frame, write_frame}, +}; +use interprocess::local_socket::tokio::{Listener, Stream, prelude::*}; +#[cfg(unix)] +use interprocess::local_socket::{GenericFilePath, ToFsName}; +#[cfg(windows)] +use interprocess::local_socket::{GenericNamespaced, ToNsName}; +use interprocess::local_socket::{ListenerOptions, Name}; + +/// Returns the default per-user socket path for the session host. +/// +/// On Unix this is `$TMPDIR/claudette-session-host/.sock` +/// (`/tmp/claudette-session-host/.sock` if `$TMPDIR` is unset). The +/// containing directory is created if missing — failures are ignored so the +/// caller still tries to bind and produces a meaningful error. +/// +/// On Windows this is `\\.\pipe\claudette-session-host-`. +pub fn default_socket_path() -> PathBuf { + let user = whoami::username(); + #[cfg(unix)] + { + let base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into()); + let dir = PathBuf::from(base).join("claudette-session-host"); + let _ = std::fs::create_dir_all(&dir); + dir.join(format!("{user}.sock")) + } + #[cfg(windows)] + { + PathBuf::from(format!(r"\\.\pipe\claudette-session-host-{user}")) + } +} + +/// Convert a filesystem path / pipe path into an `interprocess` `Name`, +/// choosing the right namespace per platform. +fn socket_name(path: &Path) -> std::io::Result> { + #[cfg(unix)] + { + path.to_fs_name::() + } + #[cfg(windows)] + { + // On Windows the "path" is really `\\.\pipe\`; only the trailing + // segment is the namespace key. + let raw = path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| std::io::Error::other("invalid pipe path"))?; + raw.to_ns_name::() + } +} + +/// Bind the listener at `socket_path` and serve connections until the task is +/// cancelled. Used by `main.rs` in production. +pub async fn run_at(socket_path: &Path) -> std::io::Result<()> { + let name = socket_name(socket_path)?; + let listener = ListenerOptions::new().name(name).create_tokio()?; + serve(listener).await +} + +/// Test entry point — same behavior as `run_at`, named distinctly so +/// integration tests can call it without pretending to be `main`. +pub async fn run_for_test(socket_path: &Path) -> std::io::Result<()> { + run_at(socket_path).await +} + +async fn serve(listener: Listener) -> std::io::Result<()> { + loop { + let stream = match listener.accept().await { + Ok(s) => s, + Err(e) => { + tracing::warn!(?e, "accept failed"); + continue; + } + }; + tokio::spawn(async move { + if let Err(e) = handle_connection(stream).await { + tracing::warn!(?e, "connection ended with error"); + } + }); + } +} + +async fn handle_connection(stream: Stream) -> std::io::Result<()> { + let (mut r, mut w) = stream.split(); + // First frame must be Hello. + let first = read_frame(&mut r).await?; + let req: Request = serde_json::from_slice(&first).map_err(std::io::Error::other)?; + let Request::Hello { + protocol_version, .. + } = req + else { + let bad = Response::Error { + message: "first frame was not Hello".into(), + recoverable: false, + }; + write_frame(&mut w, &serde_json::to_vec(&bad).unwrap()).await?; + return Ok(()); + }; + let resp = if protocol_version == PROTOCOL_VERSION { + Response::HelloAck { + protocol_version: PROTOCOL_VERSION, + host_version: env!("CARGO_PKG_VERSION").to_string(), + pid: std::process::id(), + } + } else { + Response::HelloNack { + reason: format!("unsupported protocol_version {protocol_version}"), + supported_versions: vec![PROTOCOL_VERSION], + } + }; + write_frame(&mut w, &serde_json::to_vec(&resp).unwrap()).await?; + Ok(()) +} diff --git a/src-session-host/src/session.rs b/src-session-host/src/session.rs new file mode 100644 index 000000000..ce3d22de2 --- /dev/null +++ b/src-session-host/src/session.rs @@ -0,0 +1,3 @@ +//! Per-session PTY ownership and event fan-out. +//! +//! Placeholder — populated in Task C3 (session lifecycle). diff --git a/src-session-host/tests/handshake.rs b/src-session-host/tests/handshake.rs new file mode 100644 index 000000000..fec381a29 --- /dev/null +++ b/src-session-host/tests/handshake.rs @@ -0,0 +1,58 @@ +//! End-to-end handshake test for the session-host's local-socket listener. +//! +//! Spawns the server pointed at a unique temp socket, connects a client, +//! sends `Request::Hello`, and asserts that the server replies with +//! `Response::HelloAck` for the current `PROTOCOL_VERSION`. + +use claudette::agent::interactive_protocol::{PROTOCOL_VERSION, Request, Response, frame}; +use interprocess::local_socket::tokio::{Stream, prelude::*}; +use interprocess::local_socket::{GenericFilePath, ToFsName}; + +#[tokio::test] +async fn handshake_round_trip() { + let socket_path = std::env::temp_dir().join(format!( + "claudette-handshake-test-{}.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&socket_path); + + let server = tokio::spawn({ + let sp = socket_path.clone(); + async move { + claudette_session_host::server::run_for_test(&sp) + .await + .unwrap() + } + }); + // Give the listener time to bind. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let name = socket_path + .as_path() + .to_fs_name::() + .unwrap(); + let s = Stream::connect(name).await.unwrap(); + let (mut r, mut w) = s.split(); + + let req = serde_json::to_vec(&Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: "test".into(), + }) + .unwrap(); + frame::write_frame(&mut w, &req).await.unwrap(); + + let resp_bytes = frame::read_frame(&mut r).await.unwrap(); + let resp: Response = serde_json::from_slice(&resp_bytes).unwrap(); + match resp { + Response::HelloAck { + protocol_version, .. + } => assert_eq!( + protocol_version, PROTOCOL_VERSION, + "handshake should echo our protocol version" + ), + other => panic!("expected HelloAck, got {other:?}"), + } + + server.abort(); + let _ = std::fs::remove_file(&socket_path); +} From abbc595a86b8db5c78aa9944fdf354c40881fed1 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 19:58:12 -0700 Subject: [PATCH 21/95] fix(session-host): propagate serde errors in handshake; gate test cfg(unix) --- src-session-host/src/server.rs | 12 ++++++++++-- src-session-host/tests/handshake.rs | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src-session-host/src/server.rs b/src-session-host/src/server.rs index bed918335..639a69950 100644 --- a/src-session-host/src/server.rs +++ b/src-session-host/src/server.rs @@ -110,7 +110,11 @@ async fn handle_connection(stream: Stream) -> std::io::Result<()> { message: "first frame was not Hello".into(), recoverable: false, }; - write_frame(&mut w, &serde_json::to_vec(&bad).unwrap()).await?; + write_frame( + &mut w, + &serde_json::to_vec(&bad).map_err(std::io::Error::other)?, + ) + .await?; return Ok(()); }; let resp = if protocol_version == PROTOCOL_VERSION { @@ -125,6 +129,10 @@ async fn handle_connection(stream: Stream) -> std::io::Result<()> { supported_versions: vec![PROTOCOL_VERSION], } }; - write_frame(&mut w, &serde_json::to_vec(&resp).unwrap()).await?; + write_frame( + &mut w, + &serde_json::to_vec(&resp).map_err(std::io::Error::other)?, + ) + .await?; Ok(()) } diff --git a/src-session-host/tests/handshake.rs b/src-session-host/tests/handshake.rs index fec381a29..ccde818fe 100644 --- a/src-session-host/tests/handshake.rs +++ b/src-session-host/tests/handshake.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + //! End-to-end handshake test for the session-host's local-socket listener. //! //! Spawns the server pointed at a unique temp socket, connects a client, From 21f4e3de3ac36e6035b3f16f25aad2c354b14cbb Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 20:05:21 -0700 Subject: [PATCH 22/95] feat(session-host): EnsureSession + Status + SendInput + Stop Wires the post-handshake request loop in `server.rs` and adds a per-session PTY actor in `session.rs`. The actor owns the `PtyPair`, runs a blocking reader task that fans output into a 256 KB rolling capture buffer plus a `broadcast` channel for live attaches (C4), and a waiter task that emits an `Exit` event when the child terminates. A new `SessionMap = Arc>>>` is shared across connections so all clients see the same set of live sessions. `run_at_with` lets an outer harness pass its own map; `run_at` and `run_for_test` wrap a fresh one. Dispatch handles `EnsureSession`, `Status`, `SendInput`, and `Stop`; `Resize`, `Detach`, `CaptureScreen`, and `Attach` remain `not yet implemented` (they land in C4). Integration test `ensure_session.rs` builds the workspace `stub-tui` fixture via `cargo build -p stub-tui` and discovers its path through `cargo metadata`'s `target_directory` (artifact deps still require `-Z bindeps` on 1.94, so we sidestep them). Co-Authored-By: Claude Opus 4.7 --- src-session-host/src/lib.rs | 5 + src-session-host/src/server.rs | 183 ++++++++++++++-- src-session-host/src/session.rs | 255 ++++++++++++++++++++++- src-session-host/tests/ensure_session.rs | 168 +++++++++++++++ 4 files changed, 598 insertions(+), 13 deletions(-) create mode 100644 src-session-host/tests/ensure_session.rs diff --git a/src-session-host/src/lib.rs b/src-session-host/src/lib.rs index 53d019e93..5613fb042 100644 --- a/src-session-host/src/lib.rs +++ b/src-session-host/src/lib.rs @@ -8,3 +8,8 @@ pub mod idle; pub mod server; pub mod session; + +/// Re-exported so `server::dispatch` can construct `SessionSummary` instances +/// without spelling out the full `claudette::agent::interactive_protocol` path +/// at every call site. +pub use claudette::agent::interactive_protocol::SessionSummary; diff --git a/src-session-host/src/server.rs b/src-session-host/src/server.rs index 639a69950..f1195d5fa 100644 --- a/src-session-host/src/server.rs +++ b/src-session-host/src/server.rs @@ -8,13 +8,18 @@ //! `Request::Hello`; the server replies with either `Response::HelloAck` or //! `Response::HelloNack` depending on protocol_version compatibility. //! -//! Anything past the handshake is the responsibility of later tasks -//! (C3 — session ops, C4 — event streaming, etc.). +//! After the handshake the connection enters a request/response loop. Task C3 +//! wires `EnsureSession`, `Status`, `SendInput`, and `Stop`; `Resize`, +//! `Detach`, `CaptureScreen`, and `Attach` land in C4 (they currently return +//! `Response::Error { "not yet implemented" }`). +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::Ordering; use claudette::agent::interactive_protocol::{ - PROTOCOL_VERSION, Request, Response, + PROTOCOL_VERSION, Request, Response, StopMode, frame::{read_frame, write_frame}, }; use interprocess::local_socket::tokio::{Listener, Stream, prelude::*}; @@ -23,6 +28,20 @@ use interprocess::local_socket::{GenericFilePath, ToFsName}; #[cfg(windows)] use interprocess::local_socket::{GenericNamespaced, ToNsName}; use interprocess::local_socket::{ListenerOptions, Name}; +use tokio::sync::Mutex; + +use crate::SessionSummary; +use crate::session::Session; + +/// Shared map of active sessions keyed by `sid`. Held by the server task and +/// cloned into each accepted connection so all connections see the same set +/// of live sessions. +pub type SessionMap = Arc>>>; + +/// Build a fresh, empty `SessionMap`. +pub fn new_session_map() -> SessionMap { + Arc::new(Mutex::new(HashMap::new())) +} /// Returns the default per-user socket path for the session host. /// @@ -66,21 +85,28 @@ fn socket_name(path: &Path) -> std::io::Result> { } } -/// Bind the listener at `socket_path` and serve connections until the task is -/// cancelled. Used by `main.rs` in production. -pub async fn run_at(socket_path: &Path) -> std::io::Result<()> { +/// Bind the listener at `socket_path` with the given session map and serve +/// connections until the task is cancelled. Useful when an outer harness +/// wants to inspect / share session state with the server. +pub async fn run_at_with(map: SessionMap, socket_path: &Path) -> std::io::Result<()> { let name = socket_name(socket_path)?; let listener = ListenerOptions::new().name(name).create_tokio()?; - serve(listener).await + serve(listener, map).await +} + +/// Bind the listener at `socket_path` with a fresh session map. Used by +/// `main.rs` in production. +pub async fn run_at(socket_path: &Path) -> std::io::Result<()> { + run_at_with(new_session_map(), socket_path).await } /// Test entry point — same behavior as `run_at`, named distinctly so /// integration tests can call it without pretending to be `main`. pub async fn run_for_test(socket_path: &Path) -> std::io::Result<()> { - run_at(socket_path).await + run_at_with(new_session_map(), socket_path).await } -async fn serve(listener: Listener) -> std::io::Result<()> { +async fn serve(listener: Listener, map: SessionMap) -> std::io::Result<()> { loop { let stream = match listener.accept().await { Ok(s) => s, @@ -89,15 +115,16 @@ async fn serve(listener: Listener) -> std::io::Result<()> { continue; } }; + let m = map.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection(stream).await { + if let Err(e) = handle_connection(stream, m).await { tracing::warn!(?e, "connection ended with error"); } }); } } -async fn handle_connection(stream: Stream) -> std::io::Result<()> { +async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<()> { let (mut r, mut w) = stream.split(); // First frame must be Hello. let first = read_frame(&mut r).await?; @@ -134,5 +161,137 @@ async fn handle_connection(stream: Stream) -> std::io::Result<()> { &serde_json::to_vec(&resp).map_err(std::io::Error::other)?, ) .await?; - Ok(()) + + // If the protocol version didn't match, we already sent HelloNack — close + // the connection rather than entering the request loop. + if protocol_version != PROTOCOL_VERSION { + return Ok(()); + } + + // Post-handshake dispatch loop. Each frame is one Request + one Response. + loop { + let frame_bytes = match read_frame(&mut r).await { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), + Err(e) => return Err(e), + }; + let req: Request = match serde_json::from_slice(&frame_bytes) { + Ok(v) => v, + Err(e) => { + let r = Response::Error { + message: format!("bad request: {e}"), + recoverable: false, + }; + write_frame( + &mut w, + &serde_json::to_vec(&r).map_err(std::io::Error::other)?, + ) + .await?; + continue; + } + }; + let resp = dispatch(&map, req).await; + write_frame( + &mut w, + &serde_json::to_vec(&resp).map_err(std::io::Error::other)?, + ) + .await?; + } +} + +async fn dispatch(map: &SessionMap, req: Request) -> Response { + match req { + Request::Hello { .. } => Response::Error { + message: "Hello received after handshake".into(), + recoverable: true, + }, + Request::EnsureSession { sid, spec } => { + let mut m = map.lock().await; + if let Some(s) = m.get(&sid) { + return Response::SessionStarted { + sid: s.sid.clone(), + pid: s.pid.unwrap_or(0), + rows: *s.rows.lock().await, + cols: *s.cols.lock().await, + }; + } + match Session::spawn(sid.clone(), spec).await { + Ok(s) => { + let pid = s.pid.unwrap_or(0); + let rows = *s.rows.lock().await; + let cols = *s.cols.lock().await; + m.insert(sid.clone(), s); + Response::SessionStarted { + sid, + pid, + rows, + cols, + } + } + Err(e) => Response::Error { + message: e.to_string(), + recoverable: false, + }, + } + } + Request::Status => { + let m = map.lock().await; + let mut sessions: Vec = Vec::with_capacity(m.len()); + for s in m.values() { + sessions.push(SessionSummary { + sid: s.sid.clone(), + pid: s.pid, + running: s.running.load(Ordering::SeqCst), + }); + } + Response::Status { + host_version: env!("CARGO_PKG_VERSION").into(), + sessions, + } + } + Request::SendInput { sid, payload } => { + let m = map.lock().await; + let Some(s) = m.get(&sid).cloned() else { + return Response::Error { + message: format!("not found: {sid}"), + recoverable: true, + }; + }; + drop(m); + match s.send_input(payload).await { + Ok(()) => Response::Ok, + Err(e) => Response::Error { + message: e.to_string(), + recoverable: true, + }, + } + } + Request::Stop { sid, mode } => { + let mut m = map.lock().await; + let Some(s) = m.remove(&sid) else { + return Response::Error { + message: format!("not found: {sid}"), + recoverable: true, + }; + }; + drop(m); + match mode { + StopMode::Graceful => s.stop_graceful().await, + StopMode::Force => { + // Master drop kills the child below. + } + } + // Dropping the last Arc lets the PtyPair drop and reap the child. + drop(s); + Response::Stopped { exit_status: 0 } + } + // Resize / Detach / CaptureScreen / Attach come in later tasks. + Request::Resize { .. } + | Request::Detach { .. } + | Request::CaptureScreen { .. } + | Request::Attach { .. } => Response::Error { + message: "not yet implemented".into(), + recoverable: false, + }, + } } diff --git a/src-session-host/src/session.rs b/src-session-host/src/session.rs index ce3d22de2..2ae06fbad 100644 --- a/src-session-host/src/session.rs +++ b/src-session-host/src/session.rs @@ -1,3 +1,256 @@ //! Per-session PTY ownership and event fan-out. //! -//! Placeholder — populated in Task C3 (session lifecycle). +//! A `Session` owns one `PtyPair` from `portable-pty` and the child process it +//! spawned. It exposes: +//! +//! - `spawn` — open the PTY, exec the binary, start reader + waiter tasks. +//! - `send_input` — write user input (text / decoded base64 / named key) to the +//! master writer. +//! - `resize` — propagate a new size into the PTY. +//! - `stop_graceful` — politely ask the child to exit (Ctrl+C); the server +//! handles the eventual wait + force-kill in `Stop`. +//! - `capture_screen` — return a clone of the rolling raw-ANSI capture buffer +//! (capped at 256 KB) for snapshotting in `CaptureScreen`. +//! +//! Output from the PTY is fanned out two ways: appended to the capture buffer +//! (for `CaptureScreen`) and broadcast on `tx` as `SessionEvent::Output` so +//! later attach streams (Task C4) can subscribe live. The waiter task emits +//! `SessionEvent::Exit` when the child terminates. + +use std::io::Read; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use claudette::agent::interactive_protocol::{HookFired, InputPayload, SessionSpec}; +use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize, PtySystem}; +use tokio::sync::{Mutex, broadcast}; +use tracing::info; + +/// Events broadcast to live attaches and exit-watchers. +/// +/// `Hook` is reserved for hook-fired notifications; it is unused by the +/// session itself today but lives here so attach streams (C4) and the +/// hook detector (later) can share one channel. +#[derive(Debug, Clone)] +pub enum SessionEvent { + Output { bytes: Vec, seq: u64 }, + Hook(HookFired), + Exit { exit_status: i32, reason: String }, +} + +/// One interactive Claude PTY. See module docs. +pub struct Session { + pub sid: String, + pub pid: Option, + pub rows: Mutex, + pub cols: Mutex, + /// Broadcast channel for live attaches. + pub tx: broadcast::Sender, + pty: Mutex>, + writer: Mutex>>, + /// Last screen replay bytes (capped at 256 KB). Used by `capture_screen`. + pub screen: Arc>>, + pub running: Arc, +} + +impl Session { + /// Open a PTY, exec the configured binary, and start the background + /// reader + waiter tasks. Returns a shared `Session` handle the server + /// stores in its `SessionMap`. + pub async fn spawn(sid: String, spec: SessionSpec) -> std::io::Result> { + let pty_system = NativePtySystem::default(); + let pair = pty_system + .openpty(PtySize { + rows: spec.rows, + cols: spec.cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| std::io::Error::other(e.to_string()))?; + + let mut cmd = CommandBuilder::new(&spec.claude_binary); + for arg in &spec.claude_args { + cmd.arg(arg); + } + for (k, v) in &spec.env { + cmd.env(k, v); + } + cmd.env("CLAUDE_CONFIG_DIR", &spec.claude_config_dir); + cmd.cwd(&spec.working_dir); + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|e| std::io::Error::other(e.to_string()))?; + let pid = child.process_id(); + let writer = pair + .master + .take_writer() + .map_err(|e| std::io::Error::other(e.to_string()))?; + let mut reader = pair + .master + .try_clone_reader() + .map_err(|e| std::io::Error::other(e.to_string()))?; + + let (tx, _) = broadcast::channel(2048); + let running = Arc::new(AtomicBool::new(true)); + let session = Arc::new(Self { + sid: sid.clone(), + pid, + rows: Mutex::new(spec.rows), + cols: Mutex::new(spec.cols), + tx: tx.clone(), + pty: Mutex::new(Some(pair)), + writer: Mutex::new(Some(writer)), + screen: Arc::new(Mutex::new(Vec::new())), + running: running.clone(), + }); + + // Reader task: pumps PTY output to broadcast + screen blob. + // `portable-pty`'s reader is std-blocking, so this lives on a + // blocking-pool thread. + let screen = session.screen.clone(); + let tx_reader = tx.clone(); + let running_reader = running.clone(); + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8192]; + let mut seq: u64 = 0; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + seq += 1; + let bytes = buf[..n].to_vec(); + // Capture into screen, cap at 256 KB. + { + let mut s = screen.blocking_lock(); + s.extend_from_slice(&bytes); + let max = 256 * 1024; + if s.len() > max { + let drop_to = s.len() - max; + s.drain(..drop_to); + } + } + let _ = tx_reader.send(SessionEvent::Output { bytes, seq }); + } + } + } + running_reader.store(false, Ordering::SeqCst); + }); + + // Waiter task: reaps child + emits Exit. + let tx_exit = tx.clone(); + tokio::task::spawn_blocking(move || match child.wait() { + Ok(status) => { + let code = status.exit_code() as i32; + let _ = tx_exit.send(SessionEvent::Exit { + exit_status: code, + reason: format!("child exited with {code}"), + }); + } + Err(e) => { + let _ = tx_exit.send(SessionEvent::Exit { + exit_status: -1, + reason: format!("wait failed: {e}"), + }); + } + }); + + info!(%sid, ?pid, "session spawned"); + Ok(session) + } + + /// Write `payload` to the PTY master. + /// + /// Text payloads are sent verbatim. Bytes payloads are base64-decoded + /// before write. Named keys go through `key_bytes`, which intentionally + /// returns an empty slice for unknown names so callers can't smuggle + /// arbitrary control sequences by mistake. + pub async fn send_input(&self, payload: InputPayload) -> std::io::Result<()> { + let bytes = match payload { + InputPayload::Text { text } => text.into_bytes(), + InputPayload::Bytes { bytes_b64 } => { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .decode(bytes_b64) + .map_err(std::io::Error::other)? + } + InputPayload::Keys { name } => key_bytes(&name), + }; + let mut w = self.writer.lock().await; + let Some(writer) = w.as_mut() else { + return Err(std::io::Error::other("session closed")); + }; + writer.write_all(&bytes)?; + writer.flush()?; + Ok(()) + } + + /// Resize the PTY and update the session's recorded rows/cols. + pub async fn resize(&self, rows: u16, cols: u16) -> std::io::Result<()> { + let pty = self.pty.lock().await; + let Some(pair) = pty.as_ref() else { + return Err(std::io::Error::other("session closed")); + }; + pair.master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| std::io::Error::other(e.to_string()))?; + *self.rows.lock().await = rows; + *self.cols.lock().await = cols; + Ok(()) + } + + /// Send Ctrl+C to request a graceful shutdown. The server task is + /// responsible for waiting / force-killing on top of this. + pub async fn stop_graceful(&self) { + let _ = self + .send_input(InputPayload::Keys { name: "C-c".into() }) + .await; + } + + /// Snapshot of the rolling raw-ANSI capture buffer. + pub async fn capture_screen(&self) -> Vec { + self.screen.lock().await.clone() + } +} + +/// Strict named-key matcher. Unknown names return an empty slice so callers +/// can't accidentally emit raw control bytes through a typoed key name. +fn key_bytes(name: &str) -> Vec { + match name { + "Enter" => vec![b'\r'], + "Tab" => vec![b'\t'], + "Backspace" => vec![0x7f], + "Escape" => vec![0x1b], + "C-c" => vec![0x03], + "C-d" => vec![0x04], + _ => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_bytes_known_names() { + assert_eq!(key_bytes("Enter"), b"\r"); + assert_eq!(key_bytes("Tab"), b"\t"); + assert_eq!(key_bytes("Backspace"), vec![0x7f]); + assert_eq!(key_bytes("Escape"), vec![0x1b]); + assert_eq!(key_bytes("C-c"), vec![0x03]); + assert_eq!(key_bytes("C-d"), vec![0x04]); + } + + #[test] + fn key_bytes_unknown_yields_empty() { + assert!(key_bytes("F1").is_empty()); + assert!(key_bytes("").is_empty()); + assert!(key_bytes("Ctrl+C").is_empty()); + } +} diff --git a/src-session-host/tests/ensure_session.rs b/src-session-host/tests/ensure_session.rs new file mode 100644 index 000000000..7037a625f --- /dev/null +++ b/src-session-host/tests/ensure_session.rs @@ -0,0 +1,168 @@ +#![cfg(unix)] + +//! End-to-end EnsureSession + Status test. +//! +//! Spawns the server pointed at a unique temp socket, performs the Hello +//! handshake, then issues `EnsureSession` with `stub-tui` as the executable +//! and asserts the server replied `SessionStarted`. A follow-up `Status` +//! request must include the new session as running. +//! +//! ## Locating the stub binary +//! +//! Artifact deps (`stub-tui = { ..., artifact = "bin:stub-tui" }`) would let +//! us read `env!("CARGO_BIN_EXE_stub-tui")`, but they still require +//! `-Z bindeps` on stable Rust 1.94. Instead we use `cargo metadata` to find +//! the workspace `target_directory` at test time and look up the stub binary +//! there. `cargo test -p claudette-session-host` always builds workspace +//! members it depends on transitively only — `stub-tui` is a peer crate, so +//! `find_stub_tui` calls `cargo build -p stub-tui` once before reading the +//! path. That keeps the test runnable from a clean target dir without +//! pulling stub-tui into our normal compile. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use claudette::agent::interactive_protocol::{ + PROTOCOL_VERSION, Request, Response, SessionSpec, frame, +}; +use interprocess::local_socket::tokio::{Stream, prelude::*}; +use interprocess::local_socket::{GenericFilePath, ToFsName}; + +#[tokio::test] +async fn ensure_session_starts_and_status_lists_it() { + let stub = find_stub_tui(); + + let socket = std::env::temp_dir().join(format!("ess-test-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + let server = tokio::spawn({ + let sp = socket.clone(); + async move { + claudette_session_host::server::run_for_test(&sp) + .await + .unwrap() + } + }); + // Give the listener time to bind. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // `Stream::split` consumes the stream, so we split once and pass the + // halves through every helper rather than re-splitting per-call. + let conn = open_conn(&socket).await; + let (mut r, mut w) = conn.split(); + + send_req( + &mut w, + &Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: "t".into(), + }, + ) + .await; + expect_helloack(&mut r).await; + + send_req( + &mut w, + &Request::EnsureSession { + sid: "claudette-test-aaaaaaaa".into(), + spec: SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub.to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }, + }, + ) + .await; + let resp = recv_resp(&mut r).await; + match resp { + Response::SessionStarted { sid, .. } => { + assert_eq!( + sid, "claudette-test-aaaaaaaa", + "echo of sid in SessionStarted" + ) + } + other => panic!("expected SessionStarted, got {other:?}"), + } + + send_req(&mut w, &Request::Status).await; + let st = recv_resp(&mut r).await; + match st { + Response::Status { sessions, .. } => { + assert!( + sessions + .iter() + .any(|s| s.sid == "claudette-test-aaaaaaaa" && s.running), + "Status should list the new session as running, got {sessions:?}" + ); + } + other => panic!("expected Status, got {other:?}"), + } + server.abort(); + let _ = std::fs::remove_file(&socket); +} + +// -- helpers --------------------------------------------------------------- + +async fn open_conn(path: &Path) -> Stream { + let name = path.to_fs_name::().unwrap(); + Stream::connect(name).await.unwrap() +} + +async fn send_req(w: &mut W, req: &Request) +where + W: tokio::io::AsyncWrite + Unpin, +{ + let bytes = serde_json::to_vec(req).unwrap(); + frame::write_frame(w, &bytes).await.unwrap(); +} + +async fn recv_resp(r: &mut R) -> Response +where + R: tokio::io::AsyncRead + Unpin, +{ + let buf = frame::read_frame(r).await.unwrap(); + serde_json::from_slice(&buf).unwrap() +} + +async fn expect_helloack(r: &mut R) +where + R: tokio::io::AsyncRead + Unpin, +{ + match recv_resp(r).await { + Response::HelloAck { .. } => {} + other => panic!("expected HelloAck, got {other:?}"), + } +} + +/// Locate the workspace `stub-tui` binary, building it if necessary. +/// +/// `cargo metadata` reports the workspace `target_directory`; the unsuffixed +/// binary lives directly in `/debug/`. We always `cargo build -p +/// stub-tui` first so the path exists when invoked from a clean target dir. +fn find_stub_tui() -> PathBuf { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let status = Command::new(&cargo) + .args(["build", "-p", "stub-tui"]) + .status() + .expect("failed to invoke cargo to build stub-tui"); + assert!(status.success(), "cargo build -p stub-tui failed"); + + let meta_out = Command::new(&cargo) + .args(["metadata", "--format-version", "1", "--no-deps"]) + .output() + .expect("failed to run cargo metadata"); + assert!(meta_out.status.success(), "cargo metadata failed"); + let meta: serde_json::Value = + serde_json::from_slice(&meta_out.stdout).expect("invalid cargo metadata json"); + let target_dir = meta + .get("target_directory") + .and_then(|v| v.as_str()) + .expect("metadata missing target_directory") + .to_string(); + let bin = PathBuf::from(target_dir).join("debug").join("stub-tui"); + assert!(bin.exists(), "stub-tui binary missing at {bin:?}"); + bin +} From da580c8fcb0310058d172b90d059ebae9c145b90 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 20:09:57 -0700 Subject: [PATCH 23/95] fix(session-host): drop map lock before inner-field await; document Stop exit_status semantics --- src-session-host/src/server.rs | 37 ++++++++++++++++++------ src-session-host/tests/ensure_session.rs | 32 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src-session-host/src/server.rs b/src-session-host/src/server.rs index f1195d5fa..352175d65 100644 --- a/src-session-host/src/server.rs +++ b/src-session-host/src/server.rs @@ -206,13 +206,23 @@ async fn dispatch(map: &SessionMap, req: Request) -> Response { recoverable: true, }, Request::EnsureSession { sid, spec } => { - let mut m = map.lock().await; - if let Some(s) = m.get(&sid) { + // Grab the existing session (if any) under the map lock, then drop + // the map guard before awaiting any inner-session locks. Holding + // the coarse map lock across a finer-grained `s.rows.lock().await` + // would risk deadlock with any future path that takes those locks + // in the inverse order. + let existing: Option> = { + let m = map.lock().await; + m.get(&sid).cloned() + }; + if let Some(s) = existing { + let rows = *s.rows.lock().await; + let cols = *s.cols.lock().await; return Response::SessionStarted { sid: s.sid.clone(), pid: s.pid.unwrap_or(0), - rows: *s.rows.lock().await, - cols: *s.cols.lock().await, + rows, + cols, }; } match Session::spawn(sid.clone(), spec).await { @@ -220,7 +230,10 @@ async fn dispatch(map: &SessionMap, req: Request) -> Response { let pid = s.pid.unwrap_or(0); let rows = *s.rows.lock().await; let cols = *s.cols.lock().await; - m.insert(sid.clone(), s); + { + let mut m = map.lock().await; + m.insert(sid.clone(), s); + } Response::SessionStarted { sid, pid, @@ -267,14 +280,16 @@ async fn dispatch(map: &SessionMap, req: Request) -> Response { } } Request::Stop { sid, mode } => { - let mut m = map.lock().await; - let Some(s) = m.remove(&sid) else { + let removed = { + let mut m = map.lock().await; + m.remove(&sid) + }; + let Some(s) = removed else { return Response::Error { message: format!("not found: {sid}"), recoverable: true, }; }; - drop(m); match mode { StopMode::Graceful => s.stop_graceful().await, StopMode::Force => { @@ -283,7 +298,11 @@ async fn dispatch(map: &SessionMap, req: Request) -> Response { } // Dropping the last Arc lets the PtyPair drop and reap the child. drop(s); - Response::Stopped { exit_status: 0 } + // `exit_status: -1` is a sentinel meaning "not waited" — the + // session-host does NOT block `Stop` on the child being reaped. + // The actual exit status will arrive on the attach stream as + // `SessionEvent::Exit` (see C4 for the streamed Exit event). + Response::Stopped { exit_status: -1 } } // Resize / Detach / CaptureScreen / Attach come in later tasks. Request::Resize { .. } diff --git a/src-session-host/tests/ensure_session.rs b/src-session-host/tests/ensure_session.rs index 7037a625f..bc1bc231c 100644 --- a/src-session-host/tests/ensure_session.rs +++ b/src-session-host/tests/ensure_session.rs @@ -100,6 +100,38 @@ async fn ensure_session_starts_and_status_lists_it() { } other => panic!("expected Status, got {other:?}"), } + + // Idempotency: a second EnsureSession with the same sid + spec must + // return SessionStarted echoing the same sid. This exercises the + // early-return path in the dispatch handler that previously held the + // SessionMap lock across an inner-session lock acquisition. + send_req( + &mut w, + &Request::EnsureSession { + sid: "claudette-test-aaaaaaaa".into(), + spec: SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub.to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }, + }, + ) + .await; + let resp2 = recv_resp(&mut r).await; + match resp2 { + Response::SessionStarted { sid, .. } => { + assert_eq!( + sid, "claudette-test-aaaaaaaa", + "second EnsureSession should be idempotent and echo the same sid" + ); + } + other => panic!("expected SessionStarted on idempotent EnsureSession, got {other:?}"), + } + server.abort(); let _ = std::fs::remove_file(&socket); } From 9ef0a78ad2e7ccdaad31aeaff7ad7cf7708453f9 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 20:15:28 -0700 Subject: [PATCH 24/95] feat(session-host): Attach streaming, Detach (via close), Resize, CaptureScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the four request kinds the dispatch loop had stubbed out in C3: - Attach: special-cased in handle_connection. After writing Response::AttachStarted { attach_id }, the connection switches into streaming mode and pumps SessionEvent::{Output,Hook,Exit} as wire Event frames until the client disconnects or the session exits. The per-connection attach_id_counter is a monotonic u64 so future control flows can correlate Detach requests if needed. - Detach: per the plan's simpler v1 model, the canonical way to detach is to close the socket — stream_attach exits on the resulting write error. The explicit Detach request is accepted for symmetry and returns Response::Ok (effectively a no-op). - Resize: clones the Arc out from under the map lock, then awaits Session::resize. - CaptureScreen: clones the Arc, reads the rolling raw-ANSI buffer, and returns it base64-encoded alongside current rows/cols. New integration test tests/attach_stream.rs uses two connections — a control connection for EnsureSession+SendInput and an attach connection that asserts both the AttachStarted ack and the streamed READY + "OUT: hello" echo from stub-tui. --- src-session-host/src/server.rs | 183 ++++++++++++++++++-- src-session-host/tests/attach_stream.rs | 213 ++++++++++++++++++++++++ 2 files changed, 378 insertions(+), 18 deletions(-) create mode 100644 src-session-host/tests/attach_stream.rs diff --git a/src-session-host/src/server.rs b/src-session-host/src/server.rs index 352175d65..3e58f8df8 100644 --- a/src-session-host/src/server.rs +++ b/src-session-host/src/server.rs @@ -9,17 +9,25 @@ //! `Response::HelloNack` depending on protocol_version compatibility. //! //! After the handshake the connection enters a request/response loop. Task C3 -//! wires `EnsureSession`, `Status`, `SendInput`, and `Stop`; `Resize`, -//! `Detach`, `CaptureScreen`, and `Attach` land in C4 (they currently return -//! `Response::Error { "not yet implemented" }`). +//! wired `EnsureSession`, `Status`, `SendInput`, and `Stop`; C4 adds `Resize`, +//! `CaptureScreen`, `Detach`, and the streaming `Attach` handler. +//! +//! `Attach` does not flow through the regular request/response loop: after +//! ack-ing with `Response::AttachStarted { attach_id }` the connection +//! becomes an event stream of `Event::Output` / `Event::Hook` / `Event::Exit` +//! frames until the client closes the socket or the session exits. Per the +//! plan's "simpler v1 model", a client detaches by closing the connection; +//! the explicit `Detach` request is accepted (returns `Response::Ok`) for +//! symmetry but is otherwise a no-op. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::Ordering; +use base64::Engine as _; use claudette::agent::interactive_protocol::{ - PROTOCOL_VERSION, Request, Response, StopMode, + Event, PROTOCOL_VERSION, Request, Response, StopMode, frame::{read_frame, write_frame}, }; use interprocess::local_socket::tokio::{Listener, Stream, prelude::*}; @@ -31,7 +39,7 @@ use interprocess::local_socket::{ListenerOptions, Name}; use tokio::sync::Mutex; use crate::SessionSummary; -use crate::session::Session; +use crate::session::{Session, SessionEvent}; /// Shared map of active sessions keyed by `sid`. Held by the server task and /// cloned into each accepted connection so all connections see the same set @@ -168,7 +176,13 @@ async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<( return Ok(()); } - // Post-handshake dispatch loop. Each frame is one Request + one Response. + // Post-handshake dispatch loop. Most requests are one Request + one + // Response; `Attach` is special — it ack-s with `AttachStarted` and then + // streams session events on the same connection until the session exits + // or the client closes the socket. `attach_id_counter` is bumped each + // time so clients can correlate later `Detach` requests if needed (v1 + // detaches just close the socket). + let mut attach_id_counter: u64 = 0; loop { let frame_bytes = match read_frame(&mut r).await { Ok(b) => b, @@ -190,13 +204,99 @@ async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<( continue; } }; - let resp = dispatch(&map, req).await; - write_frame( - &mut w, - &serde_json::to_vec(&resp).map_err(std::io::Error::other)?, - ) - .await?; + match req { + Request::Attach { sid } => { + let session: Option> = { + let m = map.lock().await; + m.get(&sid).cloned() + }; + let Some(s) = session else { + let r = Response::Error { + message: format!("not found: {sid}"), + recoverable: true, + }; + write_frame( + &mut w, + &serde_json::to_vec(&r).map_err(std::io::Error::other)?, + ) + .await?; + continue; + }; + attach_id_counter += 1; + let attach_id = attach_id_counter; + let ack = Response::AttachStarted { attach_id }; + write_frame( + &mut w, + &serde_json::to_vec(&ack).map_err(std::io::Error::other)?, + ) + .await?; + // Stream events on this connection until the session exits or + // the client disconnects. After streaming ends we return — the + // connection is no longer in a dispatch state because we may + // have written `Event` frames where the client expects + // `Response` frames; the cleanest contract is to close. + stream_attach(&mut w, s, sid).await?; + return Ok(()); + } + other => { + let resp = dispatch(&map, other).await; + write_frame( + &mut w, + &serde_json::to_vec(&resp).map_err(std::io::Error::other)?, + ) + .await?; + } + } + } +} + +/// Pump session events onto an attached client connection until the session +/// exits or the write side errors (i.e. the client disconnected). +/// +/// Broadcast `Lagged` errors silently terminate the stream — clients are +/// expected to re-sync via `CaptureScreen` after re-attaching. Per the plan +/// this is acceptable for v1. +async fn stream_attach(w: &mut W, sess: Arc, sid: String) -> std::io::Result<()> +where + W: tokio::io::AsyncWrite + Unpin, +{ + let mut rx = sess.tx.subscribe(); + while let Ok(ev) = rx.recv().await { + let (event, is_exit) = match ev { + SessionEvent::Output { bytes, seq } => ( + Event::Output { + sid: sid.clone(), + bytes_b64: base64::engine::general_purpose::STANDARD.encode(&bytes), + seq, + }, + false, + ), + SessionEvent::Hook(h) => ( + Event::Hook { + sid: sid.clone(), + hook: h, + }, + false, + ), + SessionEvent::Exit { + exit_status, + reason, + } => ( + Event::Exit { + sid: sid.clone(), + exit_status, + reason, + }, + true, + ), + }; + let bytes = serde_json::to_vec(&event).map_err(std::io::Error::other)?; + write_frame(w, &bytes).await?; + if is_exit { + break; + } } + Ok(()) } async fn dispatch(map: &SessionMap, req: Request) -> Response { @@ -304,12 +404,59 @@ async fn dispatch(map: &SessionMap, req: Request) -> Response { // `SessionEvent::Exit` (see C4 for the streamed Exit event). Response::Stopped { exit_status: -1 } } - // Resize / Detach / CaptureScreen / Attach come in later tasks. - Request::Resize { .. } - | Request::Detach { .. } - | Request::CaptureScreen { .. } - | Request::Attach { .. } => Response::Error { - message: "not yet implemented".into(), + Request::Resize { sid, rows, cols } => { + let session: Option> = { + let m = map.lock().await; + m.get(&sid).cloned() + }; + let Some(s) = session else { + return Response::Error { + message: format!("not found: {sid}"), + recoverable: true, + }; + }; + match s.resize(rows, cols).await { + Ok(()) => Response::Ok, + Err(e) => Response::Error { + message: e.to_string(), + recoverable: true, + }, + } + } + Request::CaptureScreen { sid } => { + let session: Option> = { + let m = map.lock().await; + m.get(&sid).cloned() + }; + let Some(s) = session else { + return Response::Error { + message: format!("not found: {sid}"), + recoverable: true, + }; + }; + let bytes = s.capture_screen().await; + let rows = *s.rows.lock().await; + let cols = *s.cols.lock().await; + Response::ScreenSnapshot { + rows, + cols, + ansi_bytes_b64: base64::engine::general_purpose::STANDARD.encode(&bytes), + } + } + // Per the simplified v1 model the canonical way to detach is to + // close the connection — `stream_attach` exits on the resulting + // write error / EOF. We accept the explicit Detach request for + // symmetry and treat it as a no-op `Ok`. (Note: Detach can never + // actually reach this branch on the attaching connection, which is + // in streaming mode and not reading frames. It can arrive on a + // sibling control connection that knows the `(sid, attach_id)` but + // has no per-attach receiver to drop, so "no-op" is the honest + // behavior.) + Request::Detach { .. } => Response::Ok, + // Attach is handled directly in `handle_connection` because it + // switches the connection into streaming mode after the ack frame. + Request::Attach { .. } => Response::Error { + message: "Attach must be handled by handle_connection".into(), recoverable: false, }, } diff --git a/src-session-host/tests/attach_stream.rs b/src-session-host/tests/attach_stream.rs new file mode 100644 index 000000000..798942244 --- /dev/null +++ b/src-session-host/tests/attach_stream.rs @@ -0,0 +1,213 @@ +#![cfg(unix)] + +//! End-to-end Attach streaming test. +//! +//! Spins up the server, opens a `control` connection that issues +//! `EnsureSession` against `stub-tui`, then opens a second `attach` +//! connection that issues `Attach` for the same sid. We assert: +//! +//! - The attach connection receives a `Response::AttachStarted { attach_id }` +//! with a monotonic `attach_id > 0`. +//! - We see the stub-tui's startup `READY\n` line on the attach stream. +//! - After sending `"hello\n"` on the control connection, the attach stream +//! surfaces the stub-tui's `OUT: hello` echo. +//! +//! The split pattern matches `ensure_session.rs`: `Stream::split` consumes +//! the stream, so we keep one `(read_half, write_half)` pair per connection +//! and pass the halves through each helper. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use base64::Engine as _; +use claudette::agent::interactive_protocol::{ + Event, InputPayload, PROTOCOL_VERSION, Request, Response, SessionSpec, frame, +}; +use interprocess::local_socket::tokio::{Stream, prelude::*}; +use interprocess::local_socket::{GenericFilePath, ToFsName}; + +#[tokio::test] +async fn attach_streams_echoed_output() { + let stub = find_stub_tui(); + + let socket = std::env::temp_dir().join(format!("attach-test-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + let server = tokio::spawn({ + let sp = socket.clone(); + async move { + claudette_session_host::server::run_for_test(&sp) + .await + .unwrap() + } + }); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Connection 1: control (handshake + EnsureSession + SendInput). + let ctrl = open_conn(&socket).await; + let (mut ctrl_r, mut ctrl_w) = ctrl.split(); + handshake(&mut ctrl_r, &mut ctrl_w).await; + + let sid = "claudette-attach-aaaaaaaa".to_string(); + send_req( + &mut ctrl_w, + &Request::EnsureSession { + sid: sid.clone(), + spec: SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub.to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }, + }, + ) + .await; + match recv_resp(&mut ctrl_r).await { + Response::SessionStarted { sid: got_sid, .. } => { + assert_eq!(got_sid, sid, "echo of sid in SessionStarted") + } + other => panic!("expected SessionStarted, got {other:?}"), + } + + // Connection 2: attach. + let att = open_conn(&socket).await; + let (mut att_r, mut att_w) = att.split(); + handshake(&mut att_r, &mut att_w).await; + + send_req(&mut att_w, &Request::Attach { sid: sid.clone() }).await; + match recv_resp(&mut att_r).await { + Response::AttachStarted { attach_id } => { + assert!(attach_id > 0, "attach_id should be monotonic > 0") + } + other => panic!("expected AttachStarted, got {other:?}"), + } + + // Drain stub-tui's `READY\n` first. + let ready_seen = drain_until_contains(&mut att_r, "READY", Duration::from_secs(2)).await; + assert!(ready_seen, "did not observe READY on attach stream"); + + // Send input on the control connection. + send_req( + &mut ctrl_w, + &Request::SendInput { + sid: sid.clone(), + payload: InputPayload::Text { + text: "hello\n".into(), + }, + }, + ) + .await; + let resp = recv_resp(&mut ctrl_r).await; + assert!(matches!(resp, Response::Ok), "expected Ok, got {resp:?}"); + + // Drain attach until we see the echo. + let seen = drain_until_contains(&mut att_r, "OUT: hello", Duration::from_secs(3)).await; + assert!(seen, "did not observe echoed line on attach stream"); + + server.abort(); + let _ = std::fs::remove_file(&socket); +} + +// -- helpers --------------------------------------------------------------- + +async fn open_conn(p: &Path) -> Stream { + let name = p.to_fs_name::().unwrap(); + Stream::connect(name).await.unwrap() +} + +async fn handshake(r: &mut R, w: &mut W) +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + send_req( + w, + &Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: "t".into(), + }, + ) + .await; + match recv_resp(r).await { + Response::HelloAck { .. } => {} + other => panic!("expected HelloAck, got {other:?}"), + } +} + +async fn send_req(w: &mut W, req: &Request) +where + W: tokio::io::AsyncWrite + Unpin, +{ + let bytes = serde_json::to_vec(req).unwrap(); + frame::write_frame(w, &bytes).await.unwrap(); +} + +async fn recv_resp(r: &mut R) -> Response +where + R: tokio::io::AsyncRead + Unpin, +{ + let buf = frame::read_frame(r).await.unwrap(); + serde_json::from_slice(&buf).unwrap() +} + +/// Pump `Event::Output` frames off `r` for up to `total`, decoding their +/// base64 bodies into a rolling buffer. Returns `true` as soon as the +/// accumulated decoded bytes contain `needle`. Non-output events (Hook, +/// Exit, StreamError) are ignored — the test only cares about stdout echo. +async fn drain_until_contains(r: &mut R, needle: &str, total: Duration) -> bool +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut buf = Vec::::new(); + let deadline = tokio::time::Instant::now() + total; + while tokio::time::Instant::now() < deadline { + let frame_res = + tokio::time::timeout(Duration::from_millis(200), frame::read_frame(r)).await; + let Ok(Ok(bytes)) = frame_res else { continue }; + let ev: Event = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(_) => continue, + }; + if let Event::Output { bytes_b64, .. } = ev { + let decoded = base64::engine::general_purpose::STANDARD + .decode(bytes_b64) + .unwrap_or_default(); + buf.extend_from_slice(&decoded); + if String::from_utf8_lossy(&buf).contains(needle) { + return true; + } + } + } + String::from_utf8_lossy(&buf).contains(needle) +} + +/// Locate the workspace `stub-tui` binary, building it if necessary. Same +/// strategy as `ensure_session.rs` — see the module doc there for why we +/// can't use `CARGO_BIN_EXE_*`. +fn find_stub_tui() -> PathBuf { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let status = Command::new(&cargo) + .args(["build", "-p", "stub-tui"]) + .status() + .expect("failed to invoke cargo to build stub-tui"); + assert!(status.success(), "cargo build -p stub-tui failed"); + + let meta_out = Command::new(&cargo) + .args(["metadata", "--format-version", "1", "--no-deps"]) + .output() + .expect("failed to run cargo metadata"); + assert!(meta_out.status.success(), "cargo metadata failed"); + let meta: serde_json::Value = + serde_json::from_slice(&meta_out.stdout).expect("invalid cargo metadata json"); + let target_dir = meta + .get("target_directory") + .and_then(|v| v.as_str()) + .expect("metadata missing target_directory") + .to_string(); + let bin = PathBuf::from(target_dir).join("debug").join("stub-tui"); + assert!(bin.exists(), "stub-tui binary missing at {bin:?}"); + bin +} From f88a86b1ef2ce29b735a4f1ab729d3a35ec4621b Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 20:18:56 -0700 Subject: [PATCH 25/95] fix(session-host): warn on attach subscriber lag; document capture_screen race --- src-session-host/src/server.rs | 85 ++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/src-session-host/src/server.rs b/src-session-host/src/server.rs index 3e58f8df8..778836020 100644 --- a/src-session-host/src/server.rs +++ b/src-session-host/src/server.rs @@ -253,47 +253,60 @@ async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<( /// Pump session events onto an attached client connection until the session /// exits or the write side errors (i.e. the client disconnected). /// -/// Broadcast `Lagged` errors silently terminate the stream — clients are +/// On broadcast `Lagged` we log a warn and close the stream — clients are /// expected to re-sync via `CaptureScreen` after re-attaching. Per the plan -/// this is acceptable for v1. +/// this is acceptable for v1. `Closed` ends the stream silently. async fn stream_attach(w: &mut W, sess: Arc, sid: String) -> std::io::Result<()> where W: tokio::io::AsyncWrite + Unpin, { let mut rx = sess.tx.subscribe(); - while let Ok(ev) = rx.recv().await { - let (event, is_exit) = match ev { - SessionEvent::Output { bytes, seq } => ( - Event::Output { - sid: sid.clone(), - bytes_b64: base64::engine::general_purpose::STANDARD.encode(&bytes), - seq, - }, - false, - ), - SessionEvent::Hook(h) => ( - Event::Hook { - sid: sid.clone(), - hook: h, - }, - false, - ), - SessionEvent::Exit { - exit_status, - reason, - } => ( - Event::Exit { - sid: sid.clone(), - exit_status, - reason, - }, - true, - ), - }; - let bytes = serde_json::to_vec(&event).map_err(std::io::Error::other)?; - write_frame(w, &bytes).await?; - if is_exit { - break; + loop { + match rx.recv().await { + Ok(ev) => { + let (event, is_exit) = match ev { + SessionEvent::Output { bytes, seq } => ( + Event::Output { + sid: sid.clone(), + bytes_b64: base64::engine::general_purpose::STANDARD.encode(&bytes), + seq, + }, + false, + ), + SessionEvent::Hook(h) => ( + Event::Hook { + sid: sid.clone(), + hook: h, + }, + false, + ), + SessionEvent::Exit { + exit_status, + reason, + } => ( + Event::Exit { + sid: sid.clone(), + exit_status, + reason, + }, + true, + ), + }; + let bytes = serde_json::to_vec(&event).map_err(std::io::Error::other)?; + write_frame(w, &bytes).await?; + if is_exit { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!( + sid = %sid, + skipped = n, + "attach subscriber lagged; closing stream", + ); + break; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } Ok(()) @@ -435,6 +448,8 @@ async fn dispatch(map: &SessionMap, req: Request) -> Response { }; }; let bytes = s.capture_screen().await; + // Two-step read: a concurrent Resize could split between rows/cols. + // Acceptable for v1 — dimensions are display hints, not load-bearing. let rows = *s.rows.lock().await; let cols = *s.cols.lock().await; Response::ScreenSnapshot { From 9ad3a1a9a10c1a56e8bbeb312d0e1e6cff86bf56 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 20:23:08 -0700 Subject: [PATCH 26/95] feat(session-host): idle-exit when no sessions and no clients Adds an Idle tracker (client counter + Notify) and wait_for_idle_exit that returns once clients==0 && session_count==0 has held continuously for the configured timeout. main.rs races the accept loop against the idle waiter via tokio::select! and exits cleanly when idle. The server gains run_at_with_idle, which wraps each connection task in a ClientGuard drop-guard so the counter decrements on any exit path (return, ? propagation, panic). Default production timeout is 600s; tests use 200ms. Co-Authored-By: Claude Opus 4.7 --- src-session-host/src/idle.rs | 126 +++++++++++++++++++++++++++- src-session-host/src/main.rs | 31 ++++++- src-session-host/src/server.rs | 42 +++++++++- src-session-host/tests/idle_exit.rs | 27 ++++++ 4 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 src-session-host/tests/idle_exit.rs diff --git a/src-session-host/src/idle.rs b/src-session-host/src/idle.rs index 3889f73cb..c7ceef41f 100644 --- a/src-session-host/src/idle.rs +++ b/src-session-host/src/idle.rs @@ -1,3 +1,127 @@ //! Idle-shutdown timer for the session host. //! -//! Placeholder — populated in Task C5 (idle-exit policy). +//! The sidecar should not linger forever once nothing useful is happening. +//! `Idle` tracks two pieces of state cooperatively with the server: +//! +//! 1. A client-connection counter. The server increments / decrements it +//! around each connection-handling task (via `client_connected` / +//! `client_disconnected`, or a drop-guard wrapper). +//! 2. Whatever is in the shared `SessionMap`. The session count is read +//! directly from the map by `wait_for_idle_exit` — we don't mirror it +//! into atomics because the map is already authoritative. +//! +//! `wait_for_idle_exit` returns once `clients == 0 && session_count == 0` +//! has held continuously for `timeout`. Any change to the client count +//! re-arms the wait via `Notify::notify_waiters`; session-count changes +//! re-arm the wait the next time the state is re-checked after a wake-up. +//! +//! `main.rs` races `wait_for_idle_exit` against the server's accept loop in +//! a `tokio::select!`; when the idle future wins, the sidecar logs and +//! exits cleanly. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use tokio::sync::Notify; + +use crate::server::SessionMap; + +/// Sidecar idle-exit tracker. Cheap to clone — all interior state is in +/// `Arc`s and shared across clones. +#[derive(Clone)] +pub struct Idle { + /// How long `clients == 0 && session_count == 0` must hold continuously + /// before `wait_for_idle_exit` returns. + pub timeout: Duration, + /// Live count of connected clients (one per accepted connection task). + clients: Arc, + /// Wakes the idle waiter whenever client / session state may have + /// changed. Public so the server can wake the waiter from places that + /// already hold `Idle` and modify session state (e.g. after a + /// `Stop` request removes a session). + pub waker: Arc, +} + +impl Idle { + /// Build a fresh tracker with the given timeout. Client count starts at + /// zero; bump it via `client_connected` as connections arrive. + pub fn new(timeout: Duration) -> Self { + Self { + timeout, + clients: Arc::new(AtomicUsize::new(0)), + waker: Arc::new(Notify::new()), + } + } + + /// Increment the client count by one and wake the idle waiter. Call + /// this when a new connection is accepted. + pub fn client_connected(&self) { + self.clients.fetch_add(1, Ordering::SeqCst); + self.waker.notify_waiters(); + } + + /// Decrement the client count by one and wake the idle waiter. Call + /// this when a connection-handling task ends (the `ClientGuard` + /// drop-guard in `server.rs` does this automatically on return / panic). + pub fn client_disconnected(&self) { + self.clients.fetch_sub(1, Ordering::SeqCst); + self.waker.notify_waiters(); + } + + /// Explicitly set the client count and wake the idle waiter. Primarily + /// useful in tests where we want to assert behavior without going + /// through the connection lifecycle. + pub fn notify_client_count(&self, n: usize) { + self.clients.store(n, Ordering::SeqCst); + self.waker.notify_waiters(); + } +} + +/// Block until the host has been idle (no clients, no sessions) for +/// `idle.timeout` continuously. The future is cancel-safe — callers race +/// it against the server's accept future in a `tokio::select!`. +/// +/// The algorithm is a small state machine: +/// +/// ```text +/// loop { +/// if clients == 0 && sessions == 0 { +/// select! { +/// timer -> re-check; if still idle, return. +/// waker -> continue (state changed; re-evaluate). +/// } +/// } else { +/// wait on waker (anything that bumps clients or session state +/// must notify_waiters). +/// } +/// } +/// ``` +/// +/// Session-count changes don't have their own waker today; they piggy-back +/// on whatever wakes us next (a connection / disconnect, or an explicit +/// `idle.waker.notify_waiters()` from the dispatch path). The re-check on +/// the other side of the sleep guarantees we don't exit prematurely if a +/// session was added during the timeout. +pub async fn wait_for_idle_exit(map: SessionMap, idle: Idle) { + loop { + let clients = idle.clients.load(Ordering::SeqCst); + let session_count = map.lock().await.len(); + if clients == 0 && session_count == 0 { + tokio::select! { + _ = tokio::time::sleep(idle.timeout) => { + let clients = idle.clients.load(Ordering::SeqCst); + let session_count = map.lock().await.len(); + if clients == 0 && session_count == 0 { + return; + } + } + _ = idle.waker.notified() => { + continue; + } + } + } else { + idle.waker.notified().await; + } + } +} diff --git a/src-session-host/src/main.rs b/src-session-host/src/main.rs index 5857426f8..3fa9c208f 100644 --- a/src-session-host/src/main.rs +++ b/src-session-host/src/main.rs @@ -3,8 +3,18 @@ //! Long-lived sidecar process. Owns claude PTYs. Exposes a JSON-line local //! socket protocol (Unix-domain socket / Named Pipe). See //! `claudette::agent::interactive_protocol`. +//! +//! The sidecar exits cleanly after `IDLE_TIMEOUT` of no clients and no +//! sessions — see `claudette_session_host::idle`. Production default is +//! 600 s; integration tests use shorter timeouts. + +use claudette_session_host::{idle, server}; -use claudette_session_host::server; +/// Default idle-exit window for production. After this much continuous time +/// with zero connected clients and zero live sessions the sidecar shuts +/// itself down. A new request from the Tauri app will respawn it on +/// demand. Mirrors the value referenced by the C5 plan section. +const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); #[tokio::main] async fn main() -> std::io::Result<()> { @@ -19,8 +29,25 @@ async fn main() -> std::io::Result<()> { tracing::info!( version = env!("CARGO_PKG_VERSION"), socket = %socket_path.display(), + idle_timeout_secs = IDLE_TIMEOUT.as_secs(), "claudette-session-host starting" ); - server::run_at(&socket_path).await + let map = server::new_session_map(); + let idle = idle::Idle::new(IDLE_TIMEOUT); + + // Race the accept loop against the idle waiter. Either branch ending + // returns from `main`; the OS reaps remaining tasks. The idle branch is + // the common "user closed Claudette" path — we exit `Ok(())` so the + // process status reflects a clean shutdown. + tokio::select! { + r = server::run_at_with_idle(map.clone(), &socket_path, idle.clone()) => r, + _ = idle::wait_for_idle_exit(map.clone(), idle.clone()) => { + tracing::info!( + idle_timeout_secs = IDLE_TIMEOUT.as_secs(), + "idle timeout reached, exiting" + ); + Ok(()) + } + } } diff --git a/src-session-host/src/server.rs b/src-session-host/src/server.rs index 778836020..448a24c6c 100644 --- a/src-session-host/src/server.rs +++ b/src-session-host/src/server.rs @@ -39,8 +39,23 @@ use interprocess::local_socket::{ListenerOptions, Name}; use tokio::sync::Mutex; use crate::SessionSummary; +use crate::idle::Idle; use crate::session::{Session, SessionEvent}; +/// RAII guard that decrements an `Idle`'s client counter on drop. The +/// server wraps each connection task with one so a panic or early `?` +/// return still re-arms the idle waiter — manual `client_disconnected` +/// calls would race against any `await` returning early. +struct ClientGuard { + idle: Idle, +} + +impl Drop for ClientGuard { + fn drop(&mut self) { + self.idle.client_disconnected(); + } +} + /// Shared map of active sessions keyed by `sid`. Held by the server task and /// cloned into each accepted connection so all connections see the same set /// of live sessions. @@ -99,7 +114,20 @@ fn socket_name(path: &Path) -> std::io::Result> { pub async fn run_at_with(map: SessionMap, socket_path: &Path) -> std::io::Result<()> { let name = socket_name(socket_path)?; let listener = ListenerOptions::new().name(name).create_tokio()?; - serve(listener, map).await + serve(listener, map, None).await +} + +/// Same as `run_at_with`, but also tracks client connection lifetimes against +/// the supplied `Idle` so a sibling `wait_for_idle_exit` future can decide +/// when to shut the sidecar down. +pub async fn run_at_with_idle( + map: SessionMap, + socket_path: &Path, + idle: Idle, +) -> std::io::Result<()> { + let name = socket_name(socket_path)?; + let listener = ListenerOptions::new().name(name).create_tokio()?; + serve(listener, map, Some(idle)).await } /// Bind the listener at `socket_path` with a fresh session map. Used by @@ -114,7 +142,7 @@ pub async fn run_for_test(socket_path: &Path) -> std::io::Result<()> { run_at_with(new_session_map(), socket_path).await } -async fn serve(listener: Listener, map: SessionMap) -> std::io::Result<()> { +async fn serve(listener: Listener, map: SessionMap, idle: Option) -> std::io::Result<()> { loop { let stream = match listener.accept().await { Ok(s) => s, @@ -124,7 +152,17 @@ async fn serve(listener: Listener, map: SessionMap) -> std::io::Result<()> { } }; let m = map.clone(); + let idle_for_task = idle.clone(); tokio::spawn(async move { + // Drop-guard pattern: bump the client count before handling the + // connection and rely on `ClientGuard`'s `Drop` impl to + // decrement it on any exit path (return, `?`, panic). This is + // strictly cleaner than explicit `client_disconnected()` calls + // because `handle_connection` returns early on bad frames. + let _client_guard = idle_for_task.map(|i| { + i.client_connected(); + ClientGuard { idle: i } + }); if let Err(e) = handle_connection(stream, m).await { tracing::warn!(?e, "connection ended with error"); } diff --git a/src-session-host/tests/idle_exit.rs b/src-session-host/tests/idle_exit.rs new file mode 100644 index 000000000..67be508a5 --- /dev/null +++ b/src-session-host/tests/idle_exit.rs @@ -0,0 +1,27 @@ +#![cfg(unix)] + +//! Idle-exit timer test for the session host. +//! +//! Constructs an `Idle` tracker with a short (200 ms) timeout, an empty +//! `SessionMap`, and zero clients, then asserts `wait_for_idle_exit` returns +//! within the expected window. This pins the C5 acceptance contract: when no +//! clients are connected AND no sessions are alive continuously for +//! `idle.timeout`, the sidecar can exit. + +#[tokio::test] +async fn idle_exit_when_no_sessions_and_no_clients() { + let map = claudette_session_host::server::new_session_map(); + let idle = claudette_session_host::idle::Idle::new(std::time::Duration::from_millis(200)); + idle.notify_client_count(0); + let started = std::time::Instant::now(); + claudette_session_host::idle::wait_for_idle_exit(map.clone(), idle.clone()).await; + let elapsed = started.elapsed(); + assert!( + elapsed >= std::time::Duration::from_millis(180), + "expected idle wait to last at least ~timeout (200ms); elapsed={elapsed:?}" + ); + assert!( + elapsed < std::time::Duration::from_millis(1_000), + "expected idle wait to return promptly after timeout; elapsed={elapsed:?}" + ); +} From fc1471d5924da0e74712cae95ca2f2c6e1422d51 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 20:51:18 -0700 Subject: [PATCH 27/95] feat(agent): SidecarHost client passes InteractiveHost conformance --- src-session-host/src/main.rs | 18 +- src-session-host/src/server.rs | 66 ++- src-session-host/tests/attach_stream.rs | 38 +- src-session-host/tests/ensure_session.rs | 33 +- src-session-host/tests/handshake.rs | 46 +- src/agent/interactive_host/sidecar.rs | 638 ++++++++++++++++++++++- src/agent/interactive_protocol.rs | 66 +++ 7 files changed, 861 insertions(+), 44 deletions(-) diff --git a/src-session-host/src/main.rs b/src-session-host/src/main.rs index 3fa9c208f..728c360ab 100644 --- a/src-session-host/src/main.rs +++ b/src-session-host/src/main.rs @@ -16,6 +16,22 @@ use claudette_session_host::{idle, server}; /// demand. Mirrors the value referenced by the C5 plan section. const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); +/// Parse the optional `--socket ` CLI argument. If absent, the +/// session-host uses `server::default_socket_path()`. This is the only flag +/// we currently support — anything more elaborate belongs in clap once the +/// surface grows. +fn parse_socket_arg() -> Option { + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + if a == "--socket" + && let Some(p) = args.next() + { + return Some(std::path::PathBuf::from(p)); + } + } + None +} + #[tokio::main] async fn main() -> std::io::Result<()> { tracing_subscriber::fmt() @@ -25,7 +41,7 @@ async fn main() -> std::io::Result<()> { ) .init(); - let socket_path = server::default_socket_path(); + let socket_path = parse_socket_arg().unwrap_or_else(server::default_socket_path); tracing::info!( version = env!("CARGO_PKG_VERSION"), socket = %socket_path.display(), diff --git a/src-session-host/src/server.rs b/src-session-host/src/server.rs index 448a24c6c..da7f87e18 100644 --- a/src-session-host/src/server.rs +++ b/src-session-host/src/server.rs @@ -27,7 +27,7 @@ use std::sync::atomic::Ordering; use base64::Engine as _; use claudette::agent::interactive_protocol::{ - Event, PROTOCOL_VERSION, Request, Response, StopMode, + Event, InboundFrame, PROTOCOL_VERSION, Request, RequestEnvelope, Response, StopMode, frame::{read_frame, write_frame}, }; use interprocess::local_socket::tokio::{Listener, Stream, prelude::*}; @@ -172,16 +172,22 @@ async fn serve(listener: Listener, map: SessionMap, idle: Option) -> std:: async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<()> { let (mut r, mut w) = stream.split(); - // First frame must be Hello. + // First frame must be a RequestEnvelope wrapping `Request::Hello`. By + // convention the Hello envelope carries `request_id == 0`, but we echo + // back whatever the client sent so a buggy client gets a useful match. let first = read_frame(&mut r).await?; - let req: Request = serde_json::from_slice(&first).map_err(std::io::Error::other)?; + let env: RequestEnvelope = serde_json::from_slice(&first).map_err(std::io::Error::other)?; + let hello_request_id = env.request_id; let Request::Hello { protocol_version, .. - } = req + } = env.request else { - let bad = Response::Error { - message: "first frame was not Hello".into(), - recoverable: false, + let bad = InboundFrame::Response { + request_id: hello_request_id, + response: Response::Error { + message: "first frame was not Hello".into(), + recoverable: false, + }, }; write_frame( &mut w, @@ -202,9 +208,13 @@ async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<( supported_versions: vec![PROTOCOL_VERSION], } }; + let outbound = InboundFrame::Response { + request_id: hello_request_id, + response: resp, + }; write_frame( &mut w, - &serde_json::to_vec(&resp).map_err(std::io::Error::other)?, + &serde_json::to_vec(&outbound).map_err(std::io::Error::other)?, ) .await?; @@ -227,12 +237,18 @@ async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<( Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), Err(e) => return Err(e), }; - let req: Request = match serde_json::from_slice(&frame_bytes) { + let env: RequestEnvelope = match serde_json::from_slice(&frame_bytes) { Ok(v) => v, Err(e) => { - let r = Response::Error { - message: format!("bad request: {e}"), - recoverable: false, + // We couldn't decode an envelope, so we have no request_id to + // correlate against. Use `0` (the Hello slot) since the client + // is already in an undefined state. + let r = InboundFrame::Response { + request_id: 0, + response: Response::Error { + message: format!("bad request envelope: {e}"), + recoverable: false, + }, }; write_frame( &mut w, @@ -242,16 +258,20 @@ async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<( continue; } }; - match req { + let request_id = env.request_id; + match env.request { Request::Attach { sid } => { let session: Option> = { let m = map.lock().await; m.get(&sid).cloned() }; let Some(s) = session else { - let r = Response::Error { - message: format!("not found: {sid}"), - recoverable: true, + let r = InboundFrame::Response { + request_id, + response: Response::Error { + message: format!("not found: {sid}"), + recoverable: true, + }, }; write_frame( &mut w, @@ -262,7 +282,10 @@ async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<( }; attach_id_counter += 1; let attach_id = attach_id_counter; - let ack = Response::AttachStarted { attach_id }; + let ack = InboundFrame::Response { + request_id, + response: Response::AttachStarted { attach_id }, + }; write_frame( &mut w, &serde_json::to_vec(&ack).map_err(std::io::Error::other)?, @@ -278,9 +301,13 @@ async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<( } other => { let resp = dispatch(&map, other).await; + let outbound = InboundFrame::Response { + request_id, + response: resp, + }; write_frame( &mut w, - &serde_json::to_vec(&resp).map_err(std::io::Error::other)?, + &serde_json::to_vec(&outbound).map_err(std::io::Error::other)?, ) .await?; } @@ -330,7 +357,8 @@ where true, ), }; - let bytes = serde_json::to_vec(&event).map_err(std::io::Error::other)?; + let frame = InboundFrame::Event(event); + let bytes = serde_json::to_vec(&frame).map_err(std::io::Error::other)?; write_frame(w, &bytes).await?; if is_exit { break; diff --git a/src-session-host/tests/attach_stream.rs b/src-session-host/tests/attach_stream.rs index 798942244..7abfdcf87 100644 --- a/src-session-host/tests/attach_stream.rs +++ b/src-session-host/tests/attach_stream.rs @@ -20,13 +20,22 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +use std::sync::atomic::{AtomicU64, Ordering}; + use base64::Engine as _; use claudette::agent::interactive_protocol::{ - Event, InputPayload, PROTOCOL_VERSION, Request, Response, SessionSpec, frame, + Event, InboundFrame, InputPayload, PROTOCOL_VERSION, Request, RequestEnvelope, Response, + SessionSpec, frame, }; use interprocess::local_socket::tokio::{Stream, prelude::*}; use interprocess::local_socket::{GenericFilePath, ToFsName}; +/// Monotonic request-id counter shared by `send_req`. Reset implicitly per +/// test process; the multiple connections opened by this test do not need +/// distinct namespaces because each connection-id correlation only flows +/// over one socket at a time. +static NEXT_REQ_ID: AtomicU64 = AtomicU64::new(1); + #[tokio::test] async fn attach_streams_echoed_output() { let stub = find_stub_tui(); @@ -141,7 +150,16 @@ async fn send_req(w: &mut W, req: &Request) where W: tokio::io::AsyncWrite + Unpin, { - let bytes = serde_json::to_vec(req).unwrap(); + let request_id = if matches!(req, Request::Hello { .. }) { + 0 + } else { + NEXT_REQ_ID.fetch_add(1, Ordering::SeqCst) + }; + let env = RequestEnvelope { + request_id, + request: req.clone(), + }; + let bytes = serde_json::to_vec(&env).unwrap(); frame::write_frame(w, &bytes).await.unwrap(); } @@ -150,13 +168,19 @@ where R: tokio::io::AsyncRead + Unpin, { let buf = frame::read_frame(r).await.unwrap(); - serde_json::from_slice(&buf).unwrap() + let inbound: InboundFrame = serde_json::from_slice(&buf).unwrap(); + match inbound { + InboundFrame::Response { response, .. } => response, + InboundFrame::Event(ev) => panic!("expected Response, got Event: {ev:?}"), + } } /// Pump `Event::Output` frames off `r` for up to `total`, decoding their /// base64 bodies into a rolling buffer. Returns `true` as soon as the /// accumulated decoded bytes contain `needle`. Non-output events (Hook, /// Exit, StreamError) are ignored — the test only cares about stdout echo. +/// +/// Events arrive wrapped in `InboundFrame::Event(...)` post-envelope cutover. async fn drain_until_contains(r: &mut R, needle: &str, total: Duration) -> bool where R: tokio::io::AsyncRead + Unpin, @@ -167,10 +191,16 @@ where let frame_res = tokio::time::timeout(Duration::from_millis(200), frame::read_frame(r)).await; let Ok(Ok(bytes)) = frame_res else { continue }; - let ev: Event = match serde_json::from_slice(&bytes) { + let inbound: InboundFrame = match serde_json::from_slice(&bytes) { Ok(v) => v, Err(_) => continue, }; + let ev = match inbound { + InboundFrame::Event(ev) => ev, + // Stray Responses on an attach stream are unexpected but not + // fatal for this helper — just skip them. + InboundFrame::Response { .. } => continue, + }; if let Event::Output { bytes_b64, .. } = ev { let decoded = base64::engine::general_purpose::STANDARD .decode(bytes_b64) diff --git a/src-session-host/tests/ensure_session.rs b/src-session-host/tests/ensure_session.rs index bc1bc231c..476a7505b 100644 --- a/src-session-host/tests/ensure_session.rs +++ b/src-session-host/tests/ensure_session.rs @@ -23,10 +23,16 @@ use std::path::{Path, PathBuf}; use std::process::Command; use claudette::agent::interactive_protocol::{ - PROTOCOL_VERSION, Request, Response, SessionSpec, frame, + InboundFrame, PROTOCOL_VERSION, Request, RequestEnvelope, Response, SessionSpec, frame, }; use interprocess::local_socket::tokio::{Stream, prelude::*}; use interprocess::local_socket::{GenericFilePath, ToFsName}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Monotonic request-id counter shared by `send_req`. Reset implicitly per +/// test process; tests do not run in parallel against the same socket so a +/// process-wide counter is fine. +static NEXT_REQ_ID: AtomicU64 = AtomicU64::new(1); #[tokio::test] async fn ensure_session_starts_and_status_lists_it() { @@ -143,20 +149,41 @@ async fn open_conn(path: &Path) -> Stream { Stream::connect(name).await.unwrap() } +/// Send a `Request` wrapped in a `RequestEnvelope` with a fresh `request_id`. +/// +/// The Hello path needs `request_id == 0`, so we treat that as a special case: +/// `Request::Hello` always goes out as `request_id = 0`. All other requests +/// allocate a monotonic id from `NEXT_REQ_ID`. async fn send_req(w: &mut W, req: &Request) where W: tokio::io::AsyncWrite + Unpin, { - let bytes = serde_json::to_vec(req).unwrap(); + let request_id = if matches!(req, Request::Hello { .. }) { + 0 + } else { + NEXT_REQ_ID.fetch_add(1, Ordering::SeqCst) + }; + let env = RequestEnvelope { + request_id, + request: req.clone(), + }; + let bytes = serde_json::to_vec(&env).unwrap(); frame::write_frame(w, &bytes).await.unwrap(); } +/// Read an `InboundFrame::Response` off the wire and unwrap to the inner +/// `Response`. Panics if an `Event` arrives — these tests only run on the +/// request/response control connection (no `Attach`). async fn recv_resp(r: &mut R) -> Response where R: tokio::io::AsyncRead + Unpin, { let buf = frame::read_frame(r).await.unwrap(); - serde_json::from_slice(&buf).unwrap() + let inbound: InboundFrame = serde_json::from_slice(&buf).unwrap(); + match inbound { + InboundFrame::Response { response, .. } => response, + InboundFrame::Event(ev) => panic!("expected Response, got Event: {ev:?}"), + } } async fn expect_helloack(r: &mut R) diff --git a/src-session-host/tests/handshake.rs b/src-session-host/tests/handshake.rs index ccde818fe..1caaaf619 100644 --- a/src-session-host/tests/handshake.rs +++ b/src-session-host/tests/handshake.rs @@ -6,7 +6,9 @@ //! sends `Request::Hello`, and asserts that the server replies with //! `Response::HelloAck` for the current `PROTOCOL_VERSION`. -use claudette::agent::interactive_protocol::{PROTOCOL_VERSION, Request, Response, frame}; +use claudette::agent::interactive_protocol::{ + InboundFrame, PROTOCOL_VERSION, Request, RequestEnvelope, Response, frame, +}; use interprocess::local_socket::tokio::{Stream, prelude::*}; use interprocess::local_socket::{GenericFilePath, ToFsName}; @@ -36,23 +38,35 @@ async fn handshake_round_trip() { let s = Stream::connect(name).await.unwrap(); let (mut r, mut w) = s.split(); - let req = serde_json::to_vec(&Request::Hello { - protocol_version: PROTOCOL_VERSION, - claudette_version: "test".into(), - }) - .unwrap(); - frame::write_frame(&mut w, &req).await.unwrap(); + // Hello is wrapped in a `RequestEnvelope` with the conventional + // `request_id = 0`. The server echoes this in the matching + // `InboundFrame::Response`. + let env = RequestEnvelope { + request_id: 0, + request: Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: "test".into(), + }, + }; + let bytes = serde_json::to_vec(&env).unwrap(); + frame::write_frame(&mut w, &bytes).await.unwrap(); let resp_bytes = frame::read_frame(&mut r).await.unwrap(); - let resp: Response = serde_json::from_slice(&resp_bytes).unwrap(); - match resp { - Response::HelloAck { - protocol_version, .. - } => assert_eq!( - protocol_version, PROTOCOL_VERSION, - "handshake should echo our protocol version" - ), - other => panic!("expected HelloAck, got {other:?}"), + let inbound: InboundFrame = serde_json::from_slice(&resp_bytes).unwrap(); + match inbound { + InboundFrame::Response { + request_id, + response: Response::HelloAck { + protocol_version, .. + }, + } => { + assert_eq!(request_id, 0, "handshake reply should echo request_id 0"); + assert_eq!( + protocol_version, PROTOCOL_VERSION, + "handshake should echo our protocol version" + ); + } + other => panic!("expected InboundFrame::Response(HelloAck), got {other:?}"), } server.abort(); diff --git a/src/agent/interactive_host/sidecar.rs b/src/agent/interactive_host/sidecar.rs index 47a073325..d566de2f5 100644 --- a/src/agent/interactive_host/sidecar.rs +++ b/src/agent/interactive_host/sidecar.rs @@ -1 +1,637 @@ -//! Placeholder — implementation lands in Task C6. +//! `SidecarHost` — the `InteractiveHost` impl that talks to +//! `claudette-session-host` over a local socket. +//! +//! Connection topology: +//! +//! - **One long-lived "control" connection** owned by the host. Used for +//! request/response traffic (`EnsureSession`, `SendInput`, +//! `CaptureScreen`, `Resize`, `Stop`, `Status`, plus the symmetric +//! no-op `Detach`). Requests are multiplexed via a monotonic +//! `request_id` and per-id `oneshot::Sender` table. +//! +//! - **One short-lived "attach" connection per `attach()` call.** The +//! session-host's `Attach` handler switches the connection into +//! streaming mode and never returns to the dispatch loop, so a fresh +//! socket is required. The attach task handshakes, sends the `Attach` +//! envelope, awaits `AttachStarted`, then pumps inbound +//! `InboundFrame::Event` frames to the caller's `AttachStream`. Closing +//! the receiver drops the task, which closes the socket, which the +//! server treats as a detach. +//! +//! This split mirrors how `attach_stream.rs` in the session-host tests +//! uses two separate connections — one for control, one for streaming. + +use super::{ + AttachEvent, AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, +}; +use crate::agent::interactive_protocol::{ + Event, InboundFrame, InputPayload, PROTOCOL_VERSION, Request, RequestEnvelope, Response, + SessionSpec, StopMode, + frame::{read_frame, write_frame}, +}; +use async_trait::async_trait; +use base64::Engine as _; +use interprocess::local_socket::Name; +use interprocess::local_socket::tokio::{Stream as SockStream, prelude::*}; +#[cfg(unix)] +use interprocess::local_socket::{GenericFilePath, ToFsName}; +#[cfg(windows)] +use interprocess::local_socket::{GenericNamespaced, ToNsName}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use tokio::sync::{Mutex, OnceCell, mpsc, oneshot}; + +/// Outbound frame the writer task picks off the mpsc. +enum OutFrame { + Bytes(Vec), +} + +/// Multiplexed control connection: one socket, many in-flight requests +/// correlated by `request_id`. Owns reader and writer Tokio tasks for the +/// lifetime of the connection. +struct ConnHandle { + tx: mpsc::Sender, + inflight: Arc>>>, + next_id: Arc, +} + +impl ConnHandle { + async fn connect(socket_path: &Path) -> std::io::Result { + let stream = open_handshaked(socket_path).await?; + let (mut r, mut w) = stream.split(); + + let (tx_out, mut rx_out) = mpsc::channel::(256); + let inflight: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let next_id = Arc::new(AtomicU64::new(1)); + + // Writer task: drains tx_out and writes frames. Exits when the + // sender side drops or a write fails — in either case the reader + // task will also notice the socket closing on its next read. + tokio::spawn(async move { + while let Some(OutFrame::Bytes(bytes)) = rx_out.recv().await { + if write_frame(&mut w, &bytes).await.is_err() { + break; + } + } + }); + + // Reader task: routes Response frames to the matching inflight + // oneshot by request_id. Events should not arrive on the control + // connection (we never send Attach over it), so we silently drop + // them if they do — this keeps the connection robust to server + // bugs without hanging. + let inflight_r = inflight.clone(); + tokio::spawn(async move { + loop { + let bytes = match read_frame(&mut r).await { + Ok(b) => b, + Err(_) => break, + }; + let Ok(frame) = serde_json::from_slice::(&bytes) else { + continue; + }; + match frame { + InboundFrame::Response { + request_id, + response, + } => { + if let Some(tx) = inflight_r.lock().await.remove(&request_id) { + let _ = tx.send(response); + } + } + InboundFrame::Event(_) => { + // Events shouldn't reach the control connection; + // the server doesn't switch us to streaming mode + // because we never send Attach here. Silently drop. + } + } + } + // Reader exited — drain inflight to wake any awaiters with a + // synthetic Error response so callers don't hang forever. + let mut g = inflight_r.lock().await; + for (_id, tx) in g.drain() { + let _ = tx.send(Response::Error { + message: "connection closed".into(), + recoverable: false, + }); + } + }); + + Ok(Self { + tx: tx_out, + inflight, + next_id, + }) + } + + /// Issue a request and await the matching response. Allocates a fresh + /// `request_id`, registers a oneshot in the inflight table, sends the + /// envelope to the writer task, then awaits the oneshot. + async fn request(&self, req: Request) -> Result { + let request_id = self + .next_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let (tx_resp, rx_resp) = oneshot::channel(); + self.inflight.lock().await.insert(request_id, tx_resp); + let env = RequestEnvelope { + request_id, + request: req, + }; + let bytes = serde_json::to_vec(&env).map_err(|e| HostError::Other(e.to_string()))?; + self.tx + .send(OutFrame::Bytes(bytes)) + .await + .map_err(|_| HostError::Other("conn closed".into()))?; + rx_resp + .await + .map_err(|_| HostError::Other("response channel dropped".into())) + } +} + +/// Open a socket connection and perform the Hello handshake. Returns the +/// (read,write)-splittable stream ready for further envelope traffic. +async fn open_handshaked(socket_path: &Path) -> std::io::Result { + let name = socket_name(socket_path)?; + let stream = SockStream::connect(name).await?; + let (mut r, mut w) = (&stream, &stream); + + // Send Hello (envelope, request_id = 0 by convention). + let hello = Request::Hello { + protocol_version: PROTOCOL_VERSION, + claudette_version: env!("CARGO_PKG_VERSION").to_string(), + }; + let env = RequestEnvelope { + request_id: 0, + request: hello, + }; + let bytes = serde_json::to_vec(&env).map_err(std::io::Error::other)?; + write_frame(&mut w, &bytes).await?; + let first = read_frame(&mut r).await?; + let inbound: InboundFrame = serde_json::from_slice(&first).map_err(std::io::Error::other)?; + match inbound { + InboundFrame::Response { + response: Response::HelloAck { .. }, + .. + } => Ok(stream), + InboundFrame::Response { response, .. } => Err(std::io::Error::other(format!( + "handshake failed: {response:?}" + ))), + InboundFrame::Event(ev) => Err(std::io::Error::other(format!( + "expected HelloAck, got event: {ev:?}" + ))), + } +} + +/// Convert a filesystem path / pipe path into an `interprocess` `Name`. +fn socket_name(path: &Path) -> std::io::Result> { + #[cfg(unix)] + { + path.to_fs_name::() + } + #[cfg(windows)] + { + let raw = path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| std::io::Error::other("invalid pipe path"))?; + raw.to_ns_name::() + } +} + +/// `InteractiveHost` impl that talks to `claudette-session-host` over a +/// local socket. Holds an exclusive long-lived control connection that's +/// created on first use and shared across every non-attach trait call. +pub struct SidecarHost { + socket_path: PathBuf, + binary_path: PathBuf, + /// Lazy-initialized once on first trait call. Wrapped in `OnceCell` so + /// concurrent first calls share a single control connection. + conn: OnceCell, +} + +impl SidecarHost { + pub fn new(socket_path: PathBuf, binary_path: PathBuf) -> Self { + Self { + socket_path, + binary_path, + conn: OnceCell::new(), + } + } + + /// Ensure the sidecar is running, spawning it if not. Idempotent. + /// + /// First tries to connect to `socket_path`. If the connect fails with + /// `ConnectionRefused` / `NotFound`, spawn `binary_path` as a detached + /// child and retry the connect for up to ~2 seconds. + pub async fn ensure_running(&self) -> std::io::Result<()> { + // Fast path: socket already responds. + if try_connect(&self.socket_path).await.is_ok() { + return Ok(()); + } + // Spawn the sidecar. + spawn_sidecar(&self.binary_path, &self.socket_path)?; + // Retry the connect briefly while the sidecar binds. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut delay = std::time::Duration::from_millis(25); + loop { + match try_connect(&self.socket_path).await { + Ok(()) => return Ok(()), + Err(e) => { + if std::time::Instant::now() >= deadline { + return Err(e); + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(std::time::Duration::from_millis(200)); + } + } + } + } + + /// Lazily initialize and return the shared control `ConnHandle`. + async fn conn(&self) -> Result<&ConnHandle, HostError> { + self.conn + .get_or_try_init(|| async { + ConnHandle::connect(&self.socket_path) + .await + .map_err(HostError::Io) + }) + .await + } +} + +/// Try opening a connection (drops it immediately on success). Used as a +/// liveness probe before deciding whether to spawn the sidecar binary. +async fn try_connect(socket_path: &Path) -> std::io::Result<()> { + let name = socket_name(socket_path)?; + let _ = SockStream::connect(name).await?; + Ok(()) +} + +/// Spawn the sidecar binary with the socket-path argument. The child runs +/// detached — we don't await its exit, and `kill_on_drop` stays at the +/// default `false` so the sidecar outlives this process if needed. +fn spawn_sidecar(binary: &Path, socket: &Path) -> std::io::Result<()> { + let mut cmd = tokio::process::Command::new(binary); + cmd.arg("--socket").arg(socket); + // Detach: don't tie the sidecar's lifetime to this process. The sidecar + // has its own idle-exit timer (see `idle_exit.rs` in session-host). + cmd.kill_on_drop(false); + let _child = cmd.spawn()?; + Ok(()) +} + +/// Open a brand-new connection, handshake, send `Attach`, await +/// `AttachStarted`, then spawn a reader task that pumps events into `tx` +/// until the connection closes. Returns the `attach_id` echoed by the +/// server. +async fn open_attach_stream( + socket_path: &Path, + sid: String, + tx: mpsc::Sender, +) -> Result { + let stream = open_handshaked(socket_path).await.map_err(HostError::Io)?; + let (mut r, mut w) = stream.split(); + + // Send Attach as request_id=1 (this connection has no other inflight + // traffic, so we don't need a counter). + let env = RequestEnvelope { + request_id: 1, + request: Request::Attach { sid: sid.clone() }, + }; + let bytes = serde_json::to_vec(&env).map_err(|e| HostError::Other(e.to_string()))?; + write_frame(&mut w, &bytes) + .await + .map_err(|e| HostError::Io(std::io::Error::other(e)))?; + + // Read the ack. + let ack_bytes = read_frame(&mut r) + .await + .map_err(|e| HostError::Io(std::io::Error::other(e)))?; + let ack: InboundFrame = serde_json::from_slice(&ack_bytes) + .map_err(|e| HostError::Protocol(format!("bad ack: {e}")))?; + let attach_id = match ack { + InboundFrame::Response { + response: Response::AttachStarted { attach_id }, + .. + } => attach_id, + InboundFrame::Response { response, .. } => { + return Err(HostError::Other(format!("Attach failed: {response:?}"))); + } + InboundFrame::Event(ev) => { + return Err(HostError::Protocol(format!( + "expected AttachStarted, got event: {ev:?}" + ))); + } + }; + + // Spawn the event pump. Owns the write half too — dropping the + // task at task-end closes the socket, which the server treats as + // a detach. + tokio::spawn(async move { + // `_w` is held so the connection stays open. Dropping it + // closes the write half; the server-side write half will + // notice when we drop the read half too. + let _w = w; + loop { + let bytes = match read_frame(&mut r).await { + Ok(b) => b, + Err(_) => break, + }; + let Ok(frame) = serde_json::from_slice::(&bytes) else { + continue; + }; + let ev_out = match frame { + InboundFrame::Event(Event::Output { bytes_b64, seq, .. }) => { + let decoded = base64::engine::general_purpose::STANDARD + .decode(bytes_b64) + .unwrap_or_default(); + AttachEvent::Output { + bytes: decoded, + seq, + } + } + InboundFrame::Event(Event::Hook { hook, .. }) => AttachEvent::Hook(hook), + InboundFrame::Event(Event::Exit { + exit_status, + reason, + .. + }) => AttachEvent::Exit { + exit_status, + reason, + }, + InboundFrame::Event(Event::StreamError { + message, + recoverable, + .. + }) => AttachEvent::Error { + message, + recoverable, + }, + // Stray Responses on an attach stream are unexpected; + // skip them. + InboundFrame::Response { .. } => continue, + }; + let was_exit = matches!(ev_out, AttachEvent::Exit { .. }); + if tx.send(ev_out).await.is_err() { + // Receiver dropped — bail out so the connection closes. + break; + } + if was_exit { + break; + } + } + }); + + Ok(attach_id) +} + +#[async_trait] +impl InteractiveHost for SidecarHost { + async fn ensure_session( + &self, + sid: &SessionId, + spec: &SessionSpec, + ) -> Result { + let conn = self.conn().await?; + let resp = conn + .request(Request::EnsureSession { + sid: sid.0.clone(), + spec: spec.clone(), + }) + .await?; + match resp { + Response::SessionStarted { + sid: got_sid, + pid, + rows, + cols, + } => Ok(HostHandle { + sid: SessionId(got_sid), + pid: Some(pid), + rows, + cols, + }), + Response::Error { message, .. } => Err(HostError::Other(message)), + other => Err(HostError::Protocol(format!( + "unexpected EnsureSession response: {other:?}" + ))), + } + } + + async fn attach(&self, sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + // Each attach opens its OWN connection. The session-host's Attach + // handler switches the connection into streaming mode and never + // returns to the dispatch loop, so reusing the multiplexed + // control connection would break every subsequent request. + let (tx, rx) = mpsc::channel::(1024); + let attach_id = open_attach_stream(&self.socket_path, sid.0.clone(), tx).await?; + use tokio_stream::wrappers::ReceiverStream; + let stream: AttachStream = Box::pin(ReceiverStream::new(rx)); + Ok((AttachId(attach_id), stream)) + } + + async fn send_input(&self, sid: &SessionId, payload: InputPayload) -> Result<(), HostError> { + let conn = self.conn().await?; + let resp = conn + .request(Request::SendInput { + sid: sid.0.clone(), + payload, + }) + .await?; + match resp { + Response::Ok => Ok(()), + Response::Error { message, .. } => Err(HostError::Other(message)), + other => Err(HostError::Protocol(format!( + "unexpected SendInput response: {other:?}" + ))), + } + } + + async fn capture_screen(&self, sid: &SessionId) -> Result { + let conn = self.conn().await?; + let resp = conn + .request(Request::CaptureScreen { sid: sid.0.clone() }) + .await?; + match resp { + Response::ScreenSnapshot { + rows, + cols, + ansi_bytes_b64, + } => { + let ansi_bytes = base64::engine::general_purpose::STANDARD + .decode(ansi_bytes_b64) + .map_err(|e| HostError::Protocol(e.to_string()))?; + Ok(ScreenSnapshot { + rows, + cols, + ansi_bytes, + }) + } + Response::Error { message, .. } => Err(HostError::Other(message)), + other => Err(HostError::Protocol(format!( + "unexpected CaptureScreen response: {other:?}" + ))), + } + } + + async fn resize(&self, sid: &SessionId, rows: u16, cols: u16) -> Result<(), HostError> { + let conn = self.conn().await?; + let resp = conn + .request(Request::Resize { + sid: sid.0.clone(), + rows, + cols, + }) + .await?; + match resp { + Response::Ok => Ok(()), + Response::Error { message, .. } => Err(HostError::Other(message)), + other => Err(HostError::Protocol(format!( + "unexpected Resize response: {other:?}" + ))), + } + } + + async fn detach(&self, sid: &SessionId, attach_id: AttachId) -> Result<(), HostError> { + let conn = self.conn().await?; + // The v1 protocol treats explicit Detach as a no-op (closing the + // attach socket is the canonical detach), but we issue it for + // symmetry and to surface protocol errors. The actual receiver + // gets `None` when the per-attach connection closes — that + // happens when the caller drops the `AttachStream` returned + // earlier. + let resp = conn + .request(Request::Detach { + sid: sid.0.clone(), + attach_id: attach_id.0, + }) + .await?; + match resp { + Response::Ok => Ok(()), + Response::Error { message, .. } => Err(HostError::Other(message)), + other => Err(HostError::Protocol(format!( + "unexpected Detach response: {other:?}" + ))), + } + } + + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError> { + let conn = self.conn().await?; + let resp = conn + .request(Request::Stop { + sid: sid.0.clone(), + mode, + }) + .await?; + match resp { + Response::Stopped { .. } | Response::Ok => Ok(()), + Response::Error { message, .. } => Err(HostError::Other(message)), + other => Err(HostError::Protocol(format!( + "unexpected Stop response: {other:?}" + ))), + } + } + + async fn status(&self) -> Result { + let conn = self.conn().await?; + let resp = conn.request(Request::Status).await?; + match resp { + Response::Status { + sessions, + host_version, + } => Ok(HostStatus { + host_version, + sessions: sessions + .into_iter() + .map(|s| HostSessionSummary { + sid: SessionId(s.sid), + pid: s.pid, + running: s.running, + }) + .collect(), + }), + Response::Error { message, .. } => Err(HostError::Other(message)), + other => Err(HostError::Protocol(format!( + "unexpected Status response: {other:?}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::interactive_host::conformance::{ConformanceFixture, run}; + use std::process::Command; + + /// Locate a workspace binary, building it first if necessary. Mirrors + /// the `find_stub_tui` helper used by the session-host integration + /// tests — we cannot use `CARGO_BIN_EXE_*` because that requires + /// `-Z bindeps` on stable. + fn find_workspace_binary(pkg: &str, bin: &str) -> PathBuf { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let status = Command::new(&cargo) + .args(["build", "-p", pkg]) + .status() + .expect("failed to invoke cargo to build workspace binary"); + assert!(status.success(), "cargo build -p {pkg} failed"); + + let meta_out = Command::new(&cargo) + .args(["metadata", "--format-version", "1", "--no-deps"]) + .output() + .expect("failed to run cargo metadata"); + assert!(meta_out.status.success(), "cargo metadata failed"); + let meta: serde_json::Value = + serde_json::from_slice(&meta_out.stdout).expect("invalid cargo metadata json"); + let target_dir = meta + .get("target_directory") + .and_then(|v| v.as_str()) + .expect("metadata missing target_directory") + .to_string(); + let path = PathBuf::from(target_dir).join("debug").join(bin); + assert!(path.exists(), "{bin} binary missing at {path:?}"); + path + } + + #[tokio::test] + #[ignore = "spawned-sidecar conformance test — run with --ignored"] + async fn sidecar_passes_conformance() { + let bin = find_workspace_binary("claudette-session-host", "claudette-session-host"); + let stub = find_workspace_binary("stub-tui", "stub-tui"); + + // Unique-per-test socket path so multiple `cargo test` invocations + // don't collide on a stale stuck sidecar. Keep the path SHORT — + // macOS `sun_path` caps Unix-domain socket paths at 104 bytes, so + // we use a short uuid prefix in `/tmp/` rather than `$TMPDIR` + // (which on macOS is `/var/folders/.../T/` and already eats ~60 + // bytes of the budget). + let short = uuid::Uuid::new_v4().simple().to_string(); + let socket = std::path::PathBuf::from("/tmp").join(format!("sh-{}.sock", &short[..8])); + let _ = std::fs::remove_file(&socket); + + let host = SidecarHost::new(socket.clone(), bin); + host.ensure_running().await.expect("ensure_running"); + + let fx = ConformanceFixture { + sid: SessionId("claudette-conformance-aaaaaaaa".into()), + spec: SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub.to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }, + }; + run(&host, &fx).await; + + // Clean up the socket file on the way out (the sidecar will idle-exit + // on its own; we just don't want a stale path lying around). + let _ = std::fs::remove_file(&socket); + } +} diff --git a/src/agent/interactive_protocol.rs b/src/agent/interactive_protocol.rs index ceb74d332..3feb75893 100644 --- a/src/agent/interactive_protocol.rs +++ b/src/agent/interactive_protocol.rs @@ -157,6 +157,33 @@ pub enum HookFired { pub const PROTOCOL_VERSION: u32 = 1; +/// Wire-level envelope for client→host requests. +/// +/// Every request frame on the socket is one of these. The `request_id` is a +/// client-side monotonic u64 that the server echoes back in the matching +/// `InboundFrame::Response` so a single connection can multiplex multiple +/// in-flight requests. By convention the initial `Request::Hello` carries +/// `request_id == 0`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RequestEnvelope { + pub request_id: u64, + pub request: Request, +} + +/// Wire-level envelope for host→client frames. +/// +/// Responses carry the originating `request_id` for correlation; events are +/// fire-and-forget (the per-session `sid` inside the `Event` is the only +/// routing key). Encoded as an untagged enum so the wire shape stays +/// `{ "request_id": .., "response": .. }` for responses and the bare event +/// JSON for events — distinguishable by the presence of `request_id`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum InboundFrame { + Response { request_id: u64, response: Response }, + Event(Event), +} + pub mod frame { use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -252,6 +279,45 @@ mod tests { }; roundtrip(&v); } + + #[test] + fn request_envelope_round_trips() { + let env = RequestEnvelope { + request_id: 42, + request: Request::Status, + }; + let s = serde_json::to_string(&env).unwrap(); + let back: RequestEnvelope = serde_json::from_str(&s).unwrap(); + assert_eq!(back.request_id, 42); + assert_eq!(back, env); + } + + #[test] + fn inbound_frame_response_round_trips() { + let frame = InboundFrame::Response { + request_id: 7, + response: Response::Ok, + }; + let s = serde_json::to_string(&frame).unwrap(); + let back: InboundFrame = serde_json::from_str(&s).unwrap(); + assert_eq!(back, frame); + } + + #[test] + fn inbound_frame_event_round_trips() { + let ev = Event::Output { + sid: "x".into(), + bytes_b64: "aGk=".into(), + seq: 1, + }; + let frame = InboundFrame::Event(ev.clone()); + let s = serde_json::to_string(&frame).unwrap(); + let back: InboundFrame = serde_json::from_str(&s).unwrap(); + match back { + InboundFrame::Event(got) => assert_eq!(got, ev), + other => panic!("expected Event variant, got {other:?}"), + } + } } #[cfg(test)] From 68ec88acc0f05e998c6f0e1f114ae35d88e9a94a Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:01:56 -0700 Subject: [PATCH 28/95] feat(agent): TmuxHost passes InteractiveHost conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `TmuxHost` as the system-tmux-backed `InteractiveHost`. Per session, the host shells out to `tmux new-session -d` with the spec's env, working dir, and Claude binary; routes output through `tmux pipe-pane` into a per-session FIFO; and fans the FIFO bytes out through `tokio::sync::broadcast` so multiple `attach()` callers each see the same output stream (required by the `multiple_attaches_each_receive_events` conformance subtest). The blocking FIFO tailer thread treats EOF as exit only after confirming via `tmux has-session` that the session is actually gone — this avoids spurious exit events on transient pipe-pane reconnects. `send_input` maps `InputPayload::{Text,Keys,Bytes}` to the corresponding `tmux send-keys -l|...` invocation; `capture_screen` uses `capture-pane -pJ -e` plus `display-message` for pane dims; `resize` calls `resize-window`; `stop` does best-effort C-c + `has-session` polling under `Graceful` then always finishes with `kill-session` and FIFO cleanup; `status` filters `list-sessions` output to `claudette-*` prefixed names. Adds `nix = "0.30"` (feature `"fs"`) under `[target.'cfg(unix)']` for `mkfifo`. The module is `#[cfg(unix)]` so Windows builds skip the whole file. Conformance test (`tmux_passes_conformance`) is `#[ignore]` and skips with an `eprintln!` when tmux isn't on PATH or is too old, matching the sidecar conformance test's pattern. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 15 +- Cargo.toml | 5 + src/agent/interactive_host/tmux.rs | 560 ++++++++++++++++++++++++++++- 3 files changed, 578 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 287034a0d..c00868ed1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,6 +887,7 @@ dependencies = [ "libc", "minisign-verify", "mlua", + "nix 0.30.1", "notify", "open", "rand 0.8.5", @@ -3907,6 +3908,18 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -4826,7 +4839,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.25.1", "serial", "shared_library", "shell-words", diff --git a/Cargo.toml b/Cargo.toml index fc1085fc5..a10bb40ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,11 @@ rodio = { version = "0.22", default-features = false, features = ["playback", "s # whose 5-minute deadline elapses, so grandchild processes (`npm install`, # build helpers) don't survive the kill of the immediate `sh -c` parent. libc = "0.2" +# Used by `agent::interactive_host::tmux` to `mkfifo` the per-session +# pipe-pane FIFO that bridges tmux output → the FIFO tailer thread → +# AttachStream subscribers. `fs` enables `nix::unistd::mkfifo` and +# `nix::sys::stat::Mode`. +nix = { version = "0.30", features = ["fs"] } [features] # `pi-sdk` gates the Pi coding-agent harness (sidecar wrapper around diff --git a/src/agent/interactive_host/tmux.rs b/src/agent/interactive_host/tmux.rs index 13eb3f6d9..33994c99c 100644 --- a/src/agent/interactive_host/tmux.rs +++ b/src/agent/interactive_host/tmux.rs @@ -1 +1,559 @@ -//! Placeholder — implementation lands in Task D1. +//! `TmuxHost` — `InteractiveHost` impl backed by the system `tmux` binary. +//! +//! Each session is a separate tmux session named after its `SessionId`. Output +//! is routed through a per-session FIFO using `tmux pipe-pane`: +//! +//! ```text +//! tmux session ── pipe-pane ──▶ FIFO ──▶ [blocking reader thread] ──▶ tokio::sync::broadcast +//! │ +//! ├─▶ Attach #1 (mpsc) +//! └─▶ Attach #2 (mpsc) +//! ``` +//! +//! A single per-session blocking reader pumps FIFO bytes into a +//! `tokio::sync::broadcast::Sender` so multiple `attach()` callers +//! each get an independent stream of the *same* output (conformance requires +//! this — see `multiple_attaches_each_receive_events`). When the reader hits +//! EOF on the FIFO and verifies via `tmux has-session` that the session is +//! gone, it broadcasts a synthetic `AttachEvent::Exit { exit_status: -1, +//! reason: "session ended" }` so attach streams unblock. +//! +//! `detach()` is a no-op at the tmux level; conformance only requires the +//! session keep running, which dropping a single subscriber doesn't affect. +//! `stop()` issues `tmux kill-session` after an optional `C-c` for graceful +//! mode and removes the FIFO file. + +#![cfg(unix)] + +use super::availability::{TmuxAvailability, check_tmux}; +use super::{ + AttachEvent, AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, +}; +use crate::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::process::Command; +use tokio::sync::Mutex; + +/// Per-session broadcast hub. Owned by `TmuxHost::sessions`; one entry per +/// live tmux session that we've issued `pipe-pane` against. +struct SessionState { + /// Broadcasts FIFO output / exit events to every `attach()` subscriber. + /// `broadcast` is the right primitive because each subscriber needs its + /// own copy of every event and slow subscribers must not block the + /// reader thread (broadcast drops the oldest item on overflow). + events: tokio::sync::broadcast::Sender, +} + +/// `InteractiveHost` impl that shells out to the system `tmux` binary. +/// +/// Holds a per-host map of live sessions so multiple `attach()` calls share a +/// single FIFO reader. `runtime_dir` is where per-session FIFOs live; the +/// caller (usually `claude_interactive.rs`) owns the directory lifetime. +pub struct TmuxHost { + runtime_dir: PathBuf, + next_attach: Arc, + /// Per-session broadcast hub, lazy-initialized on first `ensure_session`. + sessions: Arc>>, +} + +impl TmuxHost { + /// Construct a new host. `runtime_dir` will be created lazily on first + /// `ensure_session` if it doesn't exist. + pub fn new(runtime_dir: PathBuf) -> Self { + Self { + runtime_dir, + next_attach: Arc::new(AtomicU64::new(0)), + sessions: Arc::new(Mutex::new(HashMap::new())), + } + } + + fn fifo_path(&self, sid: &SessionId) -> PathBuf { + self.runtime_dir.join(format!("{}.fifo", sid.as_str())) + } +} + +/// Spawn the per-session blocking FIFO tailer. Reads bytes and pushes +/// `AttachEvent::Output` frames onto `events`. On EOF (writer side closed), +/// confirms the tmux session is actually gone before emitting +/// `AttachEvent::Exit` and terminating. Spurious 0-byte reads (FIFO refill +/// gap) trigger a short sleep + a session-liveness check. +fn spawn_fifo_tailer( + fifo: PathBuf, + sid: SessionId, + events: tokio::sync::broadcast::Sender, +) { + std::thread::spawn(move || { + use std::io::Read; + // `O_RDONLY` blocks until tmux's pipe-pane opens the write end. + let mut f = match std::fs::OpenOptions::new().read(true).open(&fifo) { + Ok(f) => f, + Err(e) => { + let _ = events.send(AttachEvent::Error { + message: format!("fifo open failed: {e}"), + recoverable: false, + }); + return; + } + }; + let mut buf = [0u8; 8192]; + let mut seq: u64 = 0; + loop { + match f.read(&mut buf) { + Ok(0) => { + // EOF: tmux's pipe-pane writer is gone. Confirm the + // session is actually dead before signalling Exit — + // tmux can briefly close pipe-pane on `respawn-pane` + // and similar without the session ending. + if !tmux_session_exists_blocking(sid.as_str()) { + let _ = events.send(AttachEvent::Exit { + exit_status: -1, + reason: "session ended".into(), + }); + return; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Ok(n) => { + seq += 1; + // `send` fails only when every subscriber has dropped. + // That's fine — the next `attach()` will mint a new + // subscriber from the same broadcast Sender and the + // tailer keeps running. + let _ = events.send(AttachEvent::Output { + bytes: buf[..n].to_vec(), + seq, + }); + } + Err(e) => { + let _ = events.send(AttachEvent::Error { + message: format!("fifo read failed: {e}"), + recoverable: false, + }); + return; + } + } + } + }); +} + +/// Synchronous (`tmux has-session`) probe used by the FIFO tailer thread, +/// which runs outside the tokio runtime and can't `await`. +fn tmux_session_exists_blocking(sid: &str) -> bool { + std::process::Command::new("tmux") + .args(["has-session", "-t", sid]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[async_trait] +impl InteractiveHost for TmuxHost { + async fn ensure_session( + &self, + sid: &SessionId, + spec: &SessionSpec, + ) -> Result { + // 1. Determine current tmux state. + let exists = Command::new("tmux") + .args(["has-session", "-t", sid.as_str()]) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false); + + // 2. Create the session + pipe-pane if missing. + if !exists { + std::fs::create_dir_all(&self.runtime_dir).map_err(HostError::Io)?; + + let mut cmd = Command::new("tmux"); + cmd.args([ + "new-session", + "-d", + "-s", + sid.as_str(), + "-x", + &spec.cols.to_string(), + "-y", + &spec.rows.to_string(), + ]); + for (k, v) in &spec.env { + cmd.args(["-e", &format!("{k}={v}")]); + } + cmd.args([ + "-e", + &format!("CLAUDE_CONFIG_DIR={}", spec.claude_config_dir), + ]); + cmd.arg("-c").arg(&spec.working_dir); + cmd.arg("--").arg(&spec.claude_binary); + for arg in &spec.claude_args { + cmd.arg(arg); + } + let st = cmd.status().await.map_err(HostError::Io)?; + if !st.success() { + return Err(HostError::Other(format!("tmux new-session failed: {st}"))); + } + + // 3. (Re)create the FIFO and wire pipe-pane to it. + let fifo = self.fifo_path(sid); + let _ = std::fs::remove_file(&fifo); + nix::unistd::mkfifo( + &fifo, + nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR, + ) + .map_err(|e| HostError::Other(format!("mkfifo {}: {e}", fifo.display())))?; + + let pipe_cmd = format!("cat >> {}", shell_escape(&fifo.to_string_lossy())); + let st = Command::new("tmux") + .args(["pipe-pane", "-O", "-t", sid.as_str(), &pipe_cmd]) + .status() + .await + .map_err(HostError::Io)?; + if !st.success() { + return Err(HostError::Other(format!("tmux pipe-pane failed: {st}"))); + } + + // 4. Create the broadcast hub and spawn the FIFO tailer. + // Capacity 1024 is generous: subscribers that fall behind by + // more than 1024 events get a `RecvError::Lagged` and skip + // forward (acceptable — TUIs repaint on the next frame). + let (tx, _rx) = tokio::sync::broadcast::channel::(1024); + spawn_fifo_tailer(fifo, sid.clone(), tx.clone()); + + self.sessions + .lock() + .await + .insert(sid.clone(), SessionState { events: tx }); + } + + Ok(HostHandle { + sid: sid.clone(), + pid: None, + rows: spec.rows, + cols: spec.cols, + }) + } + + async fn attach(&self, sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + let id = AttachId(self.next_attach.fetch_add(1, Ordering::SeqCst)); + + // Subscribe to the per-session broadcast hub. + let rx = { + let sessions = self.sessions.lock().await; + let state = sessions + .get(sid) + .ok_or_else(|| HostError::NotFound(sid.as_str().into()))?; + state.events.subscribe() + }; + + // Bridge broadcast → mpsc → AttachStream. We can't simply box the + // BroadcastStream because the trait requires + // `Stream`, while BroadcastStream yields + // `Result`. Filtering lagged events out + // here keeps the public surface clean and matches sidecar's mpsc + // shape. + let (tx, rx_mpsc) = tokio::sync::mpsc::channel::(1024); + tokio::spawn(async move { + let mut rx = rx; + loop { + match rx.recv().await { + Ok(ev) => { + let was_exit = matches!(ev, AttachEvent::Exit { .. }); + if tx.send(ev).await.is_err() { + return; + } + if was_exit { + return; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // Subscriber fell behind — skip and keep going. + // Output dropouts on a TUI heal on the next repaint. + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + } + } + }); + + use tokio_stream::wrappers::ReceiverStream; + let stream: AttachStream = Box::pin(ReceiverStream::new(rx_mpsc)); + Ok((id, stream)) + } + + async fn send_input(&self, sid: &SessionId, payload: InputPayload) -> Result<(), HostError> { + // Build the `tmux send-keys` argv. `-l` flips literal mode for + // `Text` / `Bytes`; `Keys` variants are key names like `C-c`, + // `Enter`, `PageUp` and must NOT use `-l`. + let mut args: Vec = vec!["send-keys".into(), "-t".into(), sid.as_str().into()]; + match payload { + InputPayload::Text { text } => { + args.push("-l".into()); + args.push("--".into()); + args.push(text); + } + InputPayload::Keys { name } => { + args.push("--".into()); + args.push(name); + } + InputPayload::Bytes { bytes_b64 } => { + use base64::Engine as _; + let raw = base64::engine::general_purpose::STANDARD + .decode(&bytes_b64) + .map_err(|e| HostError::Other(format!("invalid bytes_b64: {e}")))?; + let s = String::from_utf8_lossy(&raw).to_string(); + args.push("-l".into()); + args.push("--".into()); + args.push(s); + } + } + let st = Command::new("tmux") + .args(&args) + .status() + .await + .map_err(HostError::Io)?; + if !st.success() { + return Err(HostError::Other(format!("tmux send-keys failed: {st}"))); + } + Ok(()) + } + + async fn capture_screen(&self, sid: &SessionId) -> Result { + // `-p` writes to stdout, `-J` joins wrapped lines, `-e` keeps + // raw escape sequences so the frontend can replay attributes. + let out = Command::new("tmux") + .args(["capture-pane", "-t", sid.as_str(), "-pJ", "-e"]) + .output() + .await + .map_err(HostError::Io)?; + if !out.status.success() { + return Err(HostError::Other(format!( + "tmux capture-pane failed: {}", + String::from_utf8_lossy(&out.stderr) + ))); + } + + // Fetch real pane dimensions; fall back to a safe 24x80 if the + // format string returns something unparseable. + let dims = Command::new("tmux") + .args([ + "display-message", + "-p", + "-t", + sid.as_str(), + "#{pane_height},#{pane_width}", + ]) + .output() + .await + .map_err(HostError::Io)?; + let s = String::from_utf8_lossy(&dims.stdout); + let (rows, cols): (u16, u16) = { + let mut parts = s.trim().split(','); + let h = parts.next().and_then(|p| p.parse().ok()).unwrap_or(24); + let w = parts.next().and_then(|p| p.parse().ok()).unwrap_or(80); + (h, w) + }; + + Ok(ScreenSnapshot { + rows, + cols, + ansi_bytes: out.stdout, + }) + } + + async fn resize(&self, sid: &SessionId, rows: u16, cols: u16) -> Result<(), HostError> { + let st = Command::new("tmux") + .args([ + "resize-window", + "-t", + sid.as_str(), + "-x", + &cols.to_string(), + "-y", + &rows.to_string(), + ]) + .status() + .await + .map_err(HostError::Io)?; + if !st.success() { + return Err(HostError::Other(format!("tmux resize-window failed: {st}"))); + } + Ok(()) + } + + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + // The attach stream's lifetime is managed entirely by the caller + // dropping the `AttachStream`; there is no per-attach state on + // the tmux side. Returning `Ok(())` here matches the v1 protocol + // semantics — detach is a no-op for tmux. + Ok(()) + } + + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError> { + match mode { + StopMode::Graceful => { + // Best-effort C-c; ignore failure (session may already be + // gone). Then wait up to 5s for the underlying process to + // exit — same budget as the sidecar host. + let _ = Command::new("tmux") + .args(["send-keys", "-t", sid.as_str(), "--", "C-c"]) + .status() + .await; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + let still = Command::new("tmux") + .args(["has-session", "-t", sid.as_str()]) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false); + if !still { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + StopMode::Force => {} + } + // Always kill — graceful path is best-effort. + let _ = Command::new("tmux") + .args(["kill-session", "-t", sid.as_str()]) + .status() + .await + .map_err(HostError::Io)?; + + // Drop the broadcast hub. Subscribers see `RecvError::Closed` + // which closes their attach streams. The FIFO tailer thread will + // observe EOF on its next read and emit `AttachEvent::Exit` — but + // only if a subscriber survives (broadcast send fails harmlessly + // when nobody's listening). Either way, callers see exit through + // the tailer's broadcast or stream close. + let _ = self.sessions.lock().await.remove(sid); + let _ = std::fs::remove_file(self.fifo_path(sid)); + Ok(()) + } + + async fn status(&self) -> Result { + let out = Command::new("tmux") + .args(["list-sessions", "-F", "#{session_name}|#{session_created}"]) + .output() + .await; + let mut sessions = Vec::new(); + let host_version = match check_tmux().await { + TmuxAvailability::Available { version } => format!("tmux {version}"), + TmuxAvailability::TooOld { version, .. } => format!("tmux {version} (too old)"), + TmuxAvailability::NotFound => "tmux (not found)".into(), + }; + if let Ok(o) = out + && o.status.success() + { + for line in String::from_utf8_lossy(&o.stdout).lines() { + let mut parts = line.split('|'); + let name = parts.next().unwrap_or(""); + if !name.starts_with("claudette-") { + continue; + } + sessions.push(HostSessionSummary { + sid: SessionId(name.to_string()), + pid: None, + running: true, + }); + } + } + Ok(HostStatus { + host_version, + sessions, + }) + } +} + +/// POSIX-shell-safe single-quote escape for a path that will be embedded in +/// the `pipe-pane` shell command. tmux invokes the pipe command via +/// `/bin/sh -c `, so anything that survives single-quoting is safe. +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::interactive_host::conformance::{ConformanceFixture, run}; + use std::process::Command as StdCommand; + + /// Locate a workspace binary, building it first if necessary. Mirrors + /// `find_workspace_binary` in `sidecar.rs` — we can't use + /// `CARGO_BIN_EXE_*` because that requires `-Z bindeps`. + fn find_workspace_binary(pkg: &str, bin: &str) -> PathBuf { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let status = StdCommand::new(&cargo) + .args(["build", "-p", pkg]) + .status() + .expect("failed to invoke cargo to build workspace binary"); + assert!(status.success(), "cargo build -p {pkg} failed"); + + let meta_out = StdCommand::new(&cargo) + .args(["metadata", "--format-version", "1", "--no-deps"]) + .output() + .expect("failed to run cargo metadata"); + assert!(meta_out.status.success(), "cargo metadata failed"); + let meta: serde_json::Value = + serde_json::from_slice(&meta_out.stdout).expect("invalid cargo metadata json"); + let target_dir = meta + .get("target_directory") + .and_then(|v| v.as_str()) + .expect("metadata missing target_directory") + .to_string(); + let path = PathBuf::from(target_dir).join("debug").join(bin); + assert!(path.exists(), "{bin} binary missing at {path:?}"); + path + } + + #[test] + fn shell_escape_handles_single_quotes() { + assert_eq!(shell_escape("/tmp/foo"), "'/tmp/foo'"); + assert_eq!(shell_escape("/tmp/foo's"), "'/tmp/foo'\\''s'"); + assert_eq!(shell_escape(""), "''"); + } + + #[tokio::test] + #[ignore = "requires tmux >= 3.0"] + async fn tmux_passes_conformance() { + match check_tmux().await { + TmuxAvailability::Available { .. } => {} + other => { + eprintln!("skipping: tmux not available: {other:?}"); + return; + } + } + + // Per-run runtime dir under /tmp keeps FIFO paths short and + // unique across concurrent `cargo test` invocations. + let short = uuid::Uuid::new_v4().simple().to_string(); + let runtime_dir = PathBuf::from("/tmp").join(format!("th-{}", &short[..8])); + let _ = std::fs::create_dir_all(&runtime_dir); + let host = TmuxHost::new(runtime_dir.clone()); + + let stub = find_workspace_binary("stub-tui", "stub-tui"); + + let fx = ConformanceFixture { + sid: SessionId(format!("claudette-tmux-conformance-{}", &short[..8])), + spec: SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub.to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }, + }; + run(&host, &fx).await; + + // Best-effort cleanup; stop() already removed the FIFO and we + // don't care about leftover state since the dir name is uuid'd. + let _ = std::fs::remove_dir_all(&runtime_dir); + } +} From 34e4da98a3986294766bcf4573e629e9cb019f93 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:06:30 -0700 Subject: [PATCH 29/95] fix(agent): propagate tmux kill-session errors; add tracing to TmuxHost --- src/agent/interactive_host/tmux.rs | 54 ++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/agent/interactive_host/tmux.rs b/src/agent/interactive_host/tmux.rs index 33994c99c..228a508ef 100644 --- a/src/agent/interactive_host/tmux.rs +++ b/src/agent/interactive_host/tmux.rs @@ -93,6 +93,13 @@ fn spawn_fifo_tailer( let mut f = match std::fs::OpenOptions::new().read(true).open(&fifo) { Ok(f) => f, Err(e) => { + tracing::warn!( + target: "claudette::agent", + subsystem = "tmux-host", + sid = %sid.as_str(), + error = %e, + "tmux fifo open failed" + ); let _ = events.send(AttachEvent::Error { message: format!("fifo open failed: {e}"), recoverable: false, @@ -110,6 +117,12 @@ fn spawn_fifo_tailer( // tmux can briefly close pipe-pane on `respawn-pane` // and similar without the session ending. if !tmux_session_exists_blocking(sid.as_str()) { + tracing::info!( + target: "claudette::agent", + subsystem = "tmux-host", + sid = %sid.as_str(), + "tmux session ended, emitting Exit" + ); let _ = events.send(AttachEvent::Exit { exit_status: -1, reason: "session ended".into(), @@ -130,6 +143,13 @@ fn spawn_fifo_tailer( }); } Err(e) => { + tracing::warn!( + target: "claudette::agent", + subsystem = "tmux-host", + sid = %sid.as_str(), + error = %e, + "tmux fifo reader exiting on error" + ); let _ = events.send(AttachEvent::Error { message: format!("fifo read failed: {e}"), recoverable: false, @@ -195,8 +215,23 @@ impl InteractiveHost for TmuxHost { } let st = cmd.status().await.map_err(HostError::Io)?; if !st.success() { + tracing::warn!( + target: "claudette::agent", + subsystem = "tmux-host", + sid = %sid.as_str(), + status = %st, + "tmux new-session failed" + ); return Err(HostError::Other(format!("tmux new-session failed: {st}"))); } + tracing::info!( + target: "claudette::agent", + subsystem = "tmux-host", + sid = %sid.as_str(), + rows = spec.rows, + cols = spec.cols, + "tmux session spawned" + ); // 3. (Re)create the FIFO and wire pipe-pane to it. let fifo = self.fifo_path(sid); @@ -214,6 +249,13 @@ impl InteractiveHost for TmuxHost { .await .map_err(HostError::Io)?; if !st.success() { + tracing::warn!( + target: "claudette::agent", + subsystem = "tmux-host", + sid = %sid.as_str(), + status = %st, + "tmux pipe-pane setup failed" + ); return Err(HostError::Other(format!("tmux pipe-pane failed: {st}"))); } @@ -418,12 +460,20 @@ impl InteractiveHost for TmuxHost { } StopMode::Force => {} } - // Always kill — graceful path is best-effort. - let _ = Command::new("tmux") + // Always kill — graceful path is best-effort. Propagate spawn + // / wait errors via `?`; previously a stray `let _ =` swallowed + // the `Result` so I/O failures here disappeared silently. + Command::new("tmux") .args(["kill-session", "-t", sid.as_str()]) .status() .await .map_err(HostError::Io)?; + tracing::info!( + target: "claudette::agent", + subsystem = "tmux-host", + sid = %sid.as_str(), + "tmux session stopped" + ); // Drop the broadcast hub. Subscribers see `RecvError::Closed` // which closes their attach streams. The FIFO tailer thread will From 3fcd56af48144594d8d6642678c5e9c75b8d1f53 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:10:36 -0700 Subject: [PATCH 30/95] feat(agent): TmuxHost::reconcile returns sessions killed externally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reconcile() method that compares a caller-supplied set of expected SessionIds against the live tmux sessions reported by status() and returns the IDs the host no longer knows about. The chat-orchestration layer will poll this on an interval to mark externally-killed sessions dead (tmux kill-session from another shell, host reboot, OOM, etc.). Implementation is a pure post-processor on top of status() — no extra tmux invocations beyond the one status() already makes. Covered by a new ignored integration test that spawns a stub-tui session, kills it externally with `tmux kill-session`, and asserts reconcile() reports the sid as dead (and that the empty-expected case returns empty). --- src/agent/interactive_host/tmux.rs | 97 ++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/agent/interactive_host/tmux.rs b/src/agent/interactive_host/tmux.rs index 228a508ef..454f1271c 100644 --- a/src/agent/interactive_host/tmux.rs +++ b/src/agent/interactive_host/tmux.rs @@ -75,6 +75,24 @@ impl TmuxHost { fn fifo_path(&self, sid: &SessionId) -> PathBuf { self.runtime_dir.join(format!("{}.fifo", sid.as_str())) } + + /// Compare an externally-supplied set of expected `SessionId`s against the + /// host's live sessions (per `status()`) and return the IDs the host no + /// longer knows about — i.e. sessions that were killed externally (e.g. + /// `tmux kill-session` from another shell, host reboot, OOM) or never + /// existed. Callers (the chat-orchestration layer) use this to drive + /// periodic reconciliation: anything returned here should be marked dead + /// in their bookkeeping. + pub async fn reconcile(&self, expected: &[SessionId]) -> Result, HostError> { + let st = self.status().await?; + let live: std::collections::HashSet<_> = + st.sessions.iter().map(|s| s.sid.clone()).collect(); + Ok(expected + .iter() + .filter(|sid| !live.contains(sid)) + .cloned() + .collect()) + } } /// Spawn the per-session blocking FIFO tailer. Reads bytes and pushes @@ -606,4 +624,83 @@ mod tests { // don't care about leftover state since the dir name is uuid'd. let _ = std::fs::remove_dir_all(&runtime_dir); } + + #[tokio::test] + #[ignore = "requires tmux >= 3.0"] + async fn reconciliation_marks_externally_killed_sessions_dead() { + match check_tmux().await { + TmuxAvailability::Available { .. } => {} + other => { + eprintln!("skipping: tmux not available: {other:?}"); + return; + } + } + + let short = uuid::Uuid::new_v4().simple().to_string(); + let runtime_dir = PathBuf::from("/tmp").join(format!("th-rec-{}", &short[..8])); + let _ = std::fs::create_dir_all(&runtime_dir); + let host = TmuxHost::new(runtime_dir.clone()); + + let stub = find_workspace_binary("stub-tui", "stub-tui"); + let sid = SessionId(format!("claudette-tmux-reconcile-{}", &short[..8])); + let spec = SessionSpec { + working_dir: std::env::temp_dir().to_string_lossy().into(), + rows: 24, + cols: 80, + claude_binary: stub.to_string_lossy().into(), + claude_args: vec![], + env: vec![], + claude_config_dir: std::env::temp_dir().to_string_lossy().into(), + }; + + host.ensure_session(&sid, &spec) + .await + .expect("ensure_session failed"); + + // Sanity: reconcile with the live sid in expected — nothing should + // be reported dead yet. + let dead = host + .reconcile(std::slice::from_ref(&sid)) + .await + .expect("reconcile failed"); + assert!( + dead.is_empty(), + "expected no dead sessions before external kill, got {dead:?}" + ); + + // Kill the session externally (bypassing TmuxHost::stop). + let st = StdCommand::new("tmux") + .args(["kill-session", "-t", sid.as_str()]) + .status() + .expect("failed to spawn tmux kill-session"); + assert!(st.success(), "tmux kill-session did not succeed: {st}"); + + // Brief settle window for tmux internal state. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Reconcile with the killed sid in expected — it should be reported + // dead. + let dead = host + .reconcile(std::slice::from_ref(&sid)) + .await + .expect("reconcile failed"); + assert_eq!( + dead, + vec![sid.clone()], + "expected reconcile to report the killed sid as dead" + ); + + // Reconcile with empty expected — nothing to compare against, so + // result must be empty regardless of host state. + let dead = host.reconcile(&[]).await.expect("reconcile failed"); + assert!( + dead.is_empty(), + "expected empty result for empty expected, got {dead:?}" + ); + + // Best-effort cleanup; the tmux session is already gone but the + // FIFO file and runtime dir may linger. + let _ = std::fs::remove_file(host.fifo_path(&sid)); + let _ = std::fs::remove_dir_all(&runtime_dir); + } } From bee22274c5b803679b7ff86c84479868465b9c8c Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:20:34 -0700 Subject: [PATCH 31/95] feat(cli): add 'chat hook' subcommand for Claude Code hook events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `claudette chat hook --sid --kind [--reason ]` as the user-mode endpoint Claude Code's `hooks` configuration shells out to. Reads any hook payload from stdin and relays it to the running GUI via the new `chat_hook` JSON-RPC method. This is the CLI half of the hook ingestion pipeline (Task E1 of the interactive-claude plan). The GUI handler returns "not yet implemented" for now — Task E2 wires per-session interactive channels onto it. Co-Authored-By: Claude Opus 4.7 --- src-cli/src/commands/chat.rs | 8 ++ src-cli/src/commands/chat_hook.rs | 153 ++++++++++++++++++++++++++++++ src-cli/src/commands/mod.rs | 1 + src-tauri/src/ipc.rs | 47 +++++++++ 4 files changed, 209 insertions(+) create mode 100644 src-cli/src/commands/chat_hook.rs diff --git a/src-cli/src/commands/chat.rs b/src-cli/src/commands/chat.rs index 6650e56af..8df15fc73 100644 --- a/src-cli/src/commands/chat.rs +++ b/src-cli/src/commands/chat.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use clap::Subcommand; +use crate::commands::chat_hook; use crate::{discovery, ipc, output}; #[derive(Subcommand)] @@ -174,6 +175,10 @@ pub enum Action { #[arg(long)] permission: Option, }, + /// Relay a Claude Code hook event to the running GUI. Designed to + /// be invoked from a Claude Code `hooks` configuration entry; the + /// hook payload (if any) is read from stdin as JSON. + Hook(chat_hook::ChatHookArgs), } pub async fn run(action: Action, json: bool) -> Result<(), Box> { @@ -390,6 +395,9 @@ pub async fn run(action: Action, json: bool) -> Result<(), Box> { println!("ok"); } } + Action::Hook(args) => { + chat_hook::run(&info, args).await?; + } } Ok(()) } diff --git a/src-cli/src/commands/chat_hook.rs b/src-cli/src/commands/chat_hook.rs new file mode 100644 index 000000000..f6c6d4a6b --- /dev/null +++ b/src-cli/src/commands/chat_hook.rs @@ -0,0 +1,153 @@ +//! `claudette chat hook` — relay a Claude Code hook event to the +//! running GUI. +//! +//! This subcommand is the user-mode endpoint Claude Code's hook +//! configuration shells out to. The hook payload is delivered on stdin +//! as JSON; the GUI ingests it via the `chat_hook` IPC method and +//! routes the event onto the matching interactive session channel. +//! +//! Designed to be invoked from a Claude Code `hooks` entry like: +//! +//! ```text +//! { "type": "command", +//! "command": "/path/to/claudette chat hook --sid --kind " } +//! ``` +//! +//! Empty stdin is allowed — the handler decides what to do with an +//! absent payload based on `kind` / `reason`. +//! +//! Exits silently on success (no output) so it doesn't interfere with +//! Claude Code's own output stream. + +use std::error::Error; +use std::io::Read; + +use clap::Args; + +use crate::discovery::AppInfo; +use crate::ipc; + +#[derive(Args, Debug)] +pub struct ChatHookArgs { + /// Interactive session id. Must match the sid the GUI registered + /// when it spawned the interactive `claude` process. + #[arg(long)] + pub sid: String, + /// Hook kind: `stop`, `awaiting`, `prompt_submitted`, etc. Free + /// string at the protocol layer — the GUI side validates known + /// values. + #[arg(long)] + pub kind: String, + /// Optional human-readable reason (e.g. tool name that triggered + /// the awaiting state). Forwarded verbatim. + #[arg(long)] + pub reason: Option, +} + +/// Read all of stdin into a single `String`. Empty stdin is OK and +/// produces an empty string. +fn read_stdin_to_string() -> Result { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + Ok(buf) +} + +pub async fn run(info: &AppInfo, args: ChatHookArgs) -> Result<(), Box> { + let payload_stdin = read_stdin_to_string()?; + let mut params = serde_json::json!({ + "sid": args.sid, + "kind": args.kind, + "payload_stdin": payload_stdin, + }); + if let Some(reason) = args.reason { + params["reason"] = serde_json::json!(reason); + } + // The GUI handler is fire-and-forget; we don't surface its return + // value on stdout to keep this subcommand silent for the hook + // pipeline. Errors still propagate (non-zero exit + stderr). + ipc::call(info, "chat_hook", params).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + /// Mirror the top-level CLI shape just enough to exercise the + /// argument parser for the new subcommand. The real CLI in + /// `main.rs` carries other variants we don't need here — keeping a + /// minimal harness inside the test module avoids leaking the + /// top-level `Cli` type out of `main.rs` purely for testing. + #[derive(Parser, Debug)] + #[command(name = "claudette")] + struct TestCli { + #[command(subcommand)] + command: TestCommand, + } + + #[derive(clap::Subcommand, Debug)] + enum TestCommand { + Chat { + #[command(subcommand)] + action: TestChatAction, + }, + } + + #[derive(clap::Subcommand, Debug)] + enum TestChatAction { + Hook(ChatHookArgs), + } + + #[test] + fn parses_chat_hook_arguments() { + let parsed = TestCli::try_parse_from([ + "claudette", + "chat", + "hook", + "--sid", + "claudette-x-y", + "--kind", + "awaiting", + "--reason", + "blocked on permission", + ]) + .unwrap(); + let TestCommand::Chat { action } = parsed.command; + let TestChatAction::Hook(args) = action; + assert_eq!(args.sid, "claudette-x-y"); + assert_eq!(args.kind, "awaiting"); + assert_eq!(args.reason.as_deref(), Some("blocked on permission")); + } + + #[test] + fn parses_chat_hook_without_reason() { + let parsed = TestCli::try_parse_from([ + "claudette", + "chat", + "hook", + "--sid", + "sid-1", + "--kind", + "stop", + ]) + .unwrap(); + let TestCommand::Chat { action } = parsed.command; + let TestChatAction::Hook(args) = action; + assert_eq!(args.sid, "sid-1"); + assert_eq!(args.kind, "stop"); + assert!(args.reason.is_none()); + } + + #[test] + fn chat_hook_requires_sid_and_kind() { + assert!( + TestCli::try_parse_from(["claudette", "chat", "hook", "--kind", "stop"]).is_err(), + "missing --sid must fail" + ); + assert!( + TestCli::try_parse_from(["claudette", "chat", "hook", "--sid", "x"]).is_err(), + "missing --kind must fail" + ); + } +} diff --git a/src-cli/src/commands/mod.rs b/src-cli/src/commands/mod.rs index 66cf5237d..7236859e3 100644 --- a/src-cli/src/commands/mod.rs +++ b/src-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod batch; pub mod capabilities; pub mod chat; +pub mod chat_hook; pub mod plugin; pub mod pr; pub mod repo; diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index e9bc01a72..4f1edc7e1 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -88,6 +88,7 @@ const METHODS: &[&str] = &[ "routine.list", "routine.delete", "routine.run", + "chat_hook", "plugin.list", "plugin.invoke", "scm.detect_provider", @@ -429,6 +430,7 @@ async fn dispatch(app: &AppHandle, req: RpcRequest) -> RpcResponse { "routine.list" => handle_routine_list(app).await, "routine.delete" => handle_routine_delete(app, &req.params).await, "routine.run" => handle_routine_run(app, &req.params).await, + "chat_hook" => handle_chat_hook(app, &req.params).await, "plugin.list" => handle_plugin_list(app).await, "plugin.invoke" => handle_plugin_invoke(app, &req.params).await, "scm.detect_provider" => handle_scm_detect_provider(app, &req.params).await, @@ -1204,6 +1206,50 @@ async fn handle_submit_agent_approval( handle_submit_plan_approval(app, params).await } +/// Relay a Claude Code hook event (sent by `claudette-cli chat hook`) +/// into the appropriate interactive-session channel. Expected params: +/// +/// - `sid` (string, required): interactive session id the GUI handed +/// to the Claude process when it spawned. +/// - `kind` (string, required): hook kind (e.g. `stop`, `awaiting`, +/// `prompt_submitted`). +/// - `reason` (string, optional): human-readable detail. +/// - `payload_stdin` (string, optional): the raw stdin payload Claude +/// Code passed to the hook command (JSON, or empty if the hook +/// produced no stdin). +/// +/// The full event routing — registering per-session channels and +/// dispatching parsed events onto them — lands in Task E2. This +/// handler currently validates the request shape and returns an +/// explicit "not yet implemented" error so a misconfigured hook or a +/// premature integration shows up loudly instead of being silently +/// swallowed. +async fn handle_chat_hook( + _app: &AppHandle, + params: &serde_json::Value, +) -> Result { + let _sid = params + .get("sid") + .and_then(|v| v.as_str()) + .ok_or("missing sid")? + .to_string(); + let _kind = params + .get("kind") + .and_then(|v| v.as_str()) + .ok_or("missing kind")? + .to_string(); + let _reason = params + .get("reason") + .and_then(|v| v.as_str()) + .map(String::from); + let _payload_stdin = params + .get("payload_stdin") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + Err("chat_hook not yet implemented".to_string()) +} + /// Delegates to the shared `commands::workspace::archive_workspace_inner` /// helper so CLI-driven archives perform the same agent process /// teardown, env-watcher cleanup, and MCP supervisor shutdown the GUI @@ -1577,6 +1623,7 @@ mod tests { "submit_agent_approval", "submit_plan_approval", "steer_queued_chat_message", + "chat_hook", ] { assert!(METHODS.contains(&method), "{method} missing from METHODS"); } From 7f5e9689efc374f9d32ee1642adb33496c6e0c17 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:30:01 -0700 Subject: [PATCH 32/95] feat(tauri): route 'chat hook' IPC into per-session interactive channels Replaces the E1 placeholder `handle_chat_hook` with real routing into typed per-session channels: - `HookEventKind` + `InteractiveHookEvent` model the four known Claude Code hook kinds the interactive runner consumes (stop, awaiting, prompt_submitted, subagent_stop), with an `Unknown { raw_kind }` arm so a future hook surfaces in logs instead of failing the wire call. - `AppState.interactive_hook_channels` carries the per-sid sender map; register/unregister/dispatch helpers gate access and evict stale entries when a receiver was dropped without unregistering. - `handle_chat_hook` parses `sid` / `kind` / `reason` from the wire params, builds the typed event, and hands it to `AppState::dispatch_interactive_hook`. Returns `{"dispatched": true}` even when no listener is registered so legitimate startup/teardown hook arrivals don't propagate as wire errors; the no-listener case is the routine log-and-drop path. Consumers (the interactive session runner that owns the receiver half) land in F3 / G4; the public surface is stable now so the rest of the pipeline can be built against it. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/ipc.rs | 181 +++++++++++++++++++++++++++++++++++++---- src-tauri/src/state.rs | 141 +++++++++++++++++++++++++++++++- 2 files changed, 306 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 4f1edc7e1..7632f2abe 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -1212,42 +1212,69 @@ async fn handle_submit_agent_approval( /// - `sid` (string, required): interactive session id the GUI handed /// to the Claude process when it spawned. /// - `kind` (string, required): hook kind (e.g. `stop`, `awaiting`, -/// `prompt_submitted`). -/// - `reason` (string, optional): human-readable detail. +/// `prompt_submitted`, `subagent_stop`). Unknown kinds are preserved +/// as `HookEventKind::Unknown { raw_kind }` so a Claude-Code release +/// that adds a new hook still surfaces in logs instead of failing +/// the wire call. +/// - `reason` (string, optional): human-readable detail, only meaningful +/// for `awaiting`. /// - `payload_stdin` (string, optional): the raw stdin payload Claude -/// Code passed to the hook command (JSON, or empty if the hook -/// produced no stdin). +/// Code passed to the hook command. Accepted on the wire for forward +/// compatibility (Task G4 may want it) but not yet plumbed into +/// `InteractiveHookEvent`. /// -/// The full event routing — registering per-session channels and -/// dispatching parsed events onto them — lands in Task E2. This -/// handler currently validates the request shape and returns an -/// explicit "not yet implemented" error so a misconfigured hook or a -/// premature integration shows up loudly instead of being silently -/// swallowed. +/// Parses the params into an [`InteractiveHookEvent`] and hands it to +/// [`AppState::dispatch_interactive_hook`]. Returns `{"dispatched": true}` +/// regardless of whether a listener was registered — the absence of a +/// channel is a "log and drop" condition (handled inside `dispatch_…`), +/// not a wire error, because hooks legitimately fire during startup and +/// teardown when no runner is listening yet. async fn handle_chat_hook( - _app: &AppHandle, + app: &AppHandle, params: &serde_json::Value, ) -> Result { - let _sid = params + let sid = params .get("sid") .and_then(|v| v.as_str()) .ok_or("missing sid")? .to_string(); - let _kind = params + let kind_raw = params .get("kind") .and_then(|v| v.as_str()) .ok_or("missing kind")? .to_string(); - let _reason = params + let reason = params .get("reason") .and_then(|v| v.as_str()) .map(String::from); + // Accepted but currently unused; see doc comment. let _payload_stdin = params .get("payload_stdin") .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); - Err("chat_hook not yet implemented".to_string()) + + let kind = parse_hook_event_kind(&kind_raw, reason); + let state = app_state(app)?; + state.dispatch_interactive_hook(crate::state::InteractiveHookEvent { sid, kind }); + Ok(json!({"dispatched": true})) +} + +/// Map a wire `kind` string into a typed [`crate::state::HookEventKind`]. +/// `reason` is only consumed by the `awaiting` variant; for other kinds +/// it is intentionally dropped (the wire surface accepts it on every +/// hook for symmetry, but only `awaiting` needs to thread the reason +/// through to the runner). +fn parse_hook_event_kind(kind: &str, reason: Option) -> crate::state::HookEventKind { + match kind { + "stop" => crate::state::HookEventKind::Stop, + "awaiting" => crate::state::HookEventKind::Awaiting { reason }, + "prompt_submitted" => crate::state::HookEventKind::PromptSubmitted, + "subagent_stop" => crate::state::HookEventKind::SubagentStop, + other => crate::state::HookEventKind::Unknown { + raw_kind: other.to_string(), + }, + } } /// Delegates to the shared `commands::workspace::archive_workspace_inner` @@ -1933,4 +1960,128 @@ mod tests { assert_eq!(parsed.plan_mode, None); assert_eq!(parsed.model, None); } + + // ---- chat_hook routing (Task E2) ------------------------------------ + // + // `handle_chat_hook` itself takes an `&AppHandle` so it can pull + // `AppState` out of Tauri's managed-state container, which can't be + // synthesized in a pure-Rust unit test. We exercise the two pieces + // of logic that handler stitches together — `parse_hook_event_kind` + // (wire-string → typed kind) and `AppState::dispatch_interactive_hook` + // (typed event → registered channel) — directly. That's the + // equivalent of running the handler end-to-end: the handler is a + // thin compose of those two functions plus param extraction. + + fn fresh_app_state() -> crate::state::AppState { + // Same construction shape used by tray.rs's `fresh_state`. The + // DB path / worktree dir are not exercised here; only the + // interactive-hook channel map is. + let plugins = claudette::plugin_runtime::PluginRegistry::discover(std::path::Path::new( + "/nonexistent", + )); + crate::state::AppState::new( + std::path::PathBuf::from(":memory:"), + std::path::PathBuf::from("/tmp"), + plugins, + ) + } + + #[test] + fn parse_hook_event_kind_maps_known_kinds() { + use crate::state::HookEventKind; + assert_eq!(parse_hook_event_kind("stop", None), HookEventKind::Stop); + assert_eq!( + parse_hook_event_kind("awaiting", Some("perm".to_string())), + HookEventKind::Awaiting { + reason: Some("perm".to_string()) + } + ); + assert_eq!( + parse_hook_event_kind("awaiting", None), + HookEventKind::Awaiting { reason: None } + ); + assert_eq!( + parse_hook_event_kind("prompt_submitted", None), + HookEventKind::PromptSubmitted + ); + assert_eq!( + parse_hook_event_kind("subagent_stop", None), + HookEventKind::SubagentStop + ); + match parse_hook_event_kind("future_hook", None) { + HookEventKind::Unknown { raw_kind } => assert_eq!(raw_kind, "future_hook"), + other => panic!("expected Unknown, got {other:?}"), + } + } + + /// End-to-end E2 contract: a hook with `kind: "awaiting"` plus a + /// reason, dispatched to the sid the runner registered, lands on + /// the runner's receiver as the matching typed event. + #[tokio::test] + async fn dispatch_interactive_hook_delivers_awaiting_to_registered_channel() { + use crate::state::{HookEventKind, InteractiveHookEvent}; + use tokio::sync::mpsc; + + let state = fresh_app_state(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + state.register_interactive_hook_channel("claudette-x-y", tx); + + // Simulate the parse step `handle_chat_hook` runs on incoming + // wire params, then drive the same dispatch entry point. + let kind = parse_hook_event_kind("awaiting", Some("perm".to_string())); + state.dispatch_interactive_hook(InteractiveHookEvent { + sid: "claudette-x-y".to_string(), + kind, + }); + + let received = rx.recv().await.expect("channel should receive event"); + assert_eq!(received.sid, "claudette-x-y"); + assert_eq!( + received.kind, + HookEventKind::Awaiting { + reason: Some("perm".to_string()) + } + ); + } + + /// Hooks that arrive before any runner has registered must NOT + /// crash, panic, or block; they're expected during startup races + /// and the dispatcher's contract is "log and drop". The handler + /// still returns success on the wire (the caller did its job). + #[tokio::test] + async fn dispatch_interactive_hook_drops_event_with_no_listener() { + use crate::state::{HookEventKind, InteractiveHookEvent}; + + let state = fresh_app_state(); + // Intentionally do not register a channel for this sid. + state.dispatch_interactive_hook(InteractiveHookEvent { + sid: "claudette-unknown".to_string(), + kind: HookEventKind::Stop, + }); + // Nothing to assert beyond "did not panic". If the future test + // adds a metric or log capture, this is the seam to extend. + } + + /// Unregister must close the receiver loop in the runner: after + /// teardown, a hook for the same sid takes the "no listener" path + /// and the previously-registered receiver no longer receives. + #[tokio::test] + async fn unregister_interactive_hook_channel_stops_delivery() { + use crate::state::{HookEventKind, InteractiveHookEvent}; + use tokio::sync::mpsc; + + let state = fresh_app_state(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + state.register_interactive_hook_channel("claudette-x-y", tx); + state.unregister_interactive_hook_channel("claudette-x-y"); + + state.dispatch_interactive_hook(InteractiveHookEvent { + sid: "claudette-x-y".to_string(), + kind: HookEventKind::Stop, + }); + + // The sender was dropped by unregister; the only sender held + // outside the map is gone, so the channel is closed. + assert!(rx.recv().await.is_none(), "channel should be closed"); + } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 674ed7c47..ec9d95b47 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use claudette::agent::AgentSession; -use tokio::sync::{RwLock, Semaphore}; +use tokio::sync::{RwLock, Semaphore, mpsc}; use claudette::claude_help::ClaudeFlagDef; use claudette::env_provider::{EnvCache, EnvWatcher}; @@ -59,6 +59,50 @@ pub enum AttentionKind { Plan, } +/// Lifecycle signal received from a Claude Code hook (relayed by +/// `claudette-cli chat hook`) for an interactive session. Variants +/// mirror the Claude Code hook surface the interactive runner cares +/// about; the `Unknown` arm preserves forward-compatibility so an +/// unrecognized hook name shows up in logs instead of silently +/// failing on the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookEventKind { + /// `Stop` hook — the active turn finished. The runner uses this to + /// know it can dispatch the next queued prompt or hand control back + /// to the user. + Stop, + /// `Notification` hook fired with an `awaiting`-style reason + /// (permission prompt, idle-while-waiting-for-input, etc). The + /// optional `reason` carries the raw Claude-Code reason string so + /// the runner can distinguish permission asks from generic + /// awaits when surfacing UI. + Awaiting { reason: Option }, + /// `UserPromptSubmit` hook — Claude Code accepted a prompt and is + /// about to start work on it. Emitted by the cli relay when the + /// interactive driver wants to know that its enqueued prompt was + /// actually delivered (rather than buffered in the CLI's input + /// queue). + PromptSubmitted, + /// `SubagentStop` hook — a nested Task subagent finished. Distinct + /// from `Stop` because the top-level turn is still in flight. + SubagentStop, + /// Any hook kind the runner doesn't know about. Carries the raw + /// kind string so log lines can still identify what arrived. + Unknown { raw_kind: String }, +} + +/// One hook event delivered to a single interactive session. The `sid` +/// is the synthetic interactive session id Claudette stamps onto the +/// child Claude Code process via `--session-id` (or env var) at spawn +/// time; the cli relay echoes that back via `claudette-cli chat hook +/// --sid …` so dispatch can fan a single CLI invocation to the right +/// session-scoped channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InteractiveHookEvent { + pub sid: String, + pub kind: HookEventKind, +} + /// A `control_request: can_use_tool` the CLI is waiting on the host to /// resolve via `control_response`. Keyed in `AgentSessionState::pending_permissions` /// by tool_use_id so UI callbacks (AgentQuestionCard, PlanApprovalCard) can @@ -735,6 +779,19 @@ pub struct AppState { /// deleted, or manually fired so the polling loop can recompute its next /// deadline immediately instead of waiting for the fallback tick. pub scheduler_notify: Arc, + /// Per-interactive-session hook delivery channels, keyed by the + /// synthetic interactive session id (`sid`). Producers are + /// `claudette-cli chat hook` invocations routed through `ipc.rs`; + /// consumers are the per-session interactive runners (Task F3/G4) + /// that own the receiving end of the matching `mpsc::UnboundedReceiver`. + /// A sender registered here without a live receiver is fine — the + /// receiver simply isn't draining yet — but a hook arriving with no + /// registered sender is logged and dropped, since there is nobody to + /// hand the event to. `Arc>` (the std mutex) is sufficient + /// because the critical section is a single HashMap op; there's no + /// await held inside it. + pub interactive_hook_channels: + Arc>>>, } impl AppState { @@ -791,6 +848,7 @@ impl AppState { bulk_cleanup_cancels: RwLock::new(HashMap::new()), workspace_creates_in_flight: Mutex::new(HashSet::new()), scheduler_notify: Arc::new(tokio::sync::Notify::new()), + interactive_hook_channels: Arc::new(Mutex::new(HashMap::new())), } } @@ -821,6 +879,87 @@ impl AppState { .remove(repo_id); } + /// Register the sender half of a per-session hook channel. Replaces + /// any existing sender for `sid` (the previous receiver will see its + /// stream end on the next poll). The interactive runner is expected + /// to call [`Self::unregister_interactive_hook_channel`] in its + /// teardown path so the map doesn't grow without bound across + /// session restarts. + /// + /// Not yet wired into a production call site: the interactive + /// runner (Tasks F3 / G4) is the eventual caller. Exposed now so + /// the routing pipeline lands and tests as a whole and so the + /// public surface is stable before the consumer arrives. + #[allow(dead_code)] + pub fn register_interactive_hook_channel( + &self, + sid: &str, + tx: mpsc::UnboundedSender, + ) { + let mut map = self + .interactive_hook_channels + .lock() + .expect("interactive_hook_channels poisoned"); + map.insert(sid.to_string(), tx); + } + + /// Drop the sender half of a per-session hook channel. Safe to call + /// for an unknown `sid` (no-op). Doing this on session teardown + /// closes the receiver loop in the runner. + /// + /// See [`Self::register_interactive_hook_channel`] for the F3 / G4 + /// production caller; tracked here so the public surface lands + /// with E2. + #[allow(dead_code)] + pub fn unregister_interactive_hook_channel(&self, sid: &str) { + let mut map = self + .interactive_hook_channels + .lock() + .expect("interactive_hook_channels poisoned"); + map.remove(sid); + } + + /// Deliver `event` to whichever interactive runner is registered for + /// `event.sid`. If no channel is registered, log a warning and drop + /// the event — this is the common case during startup (CLI hook + /// fires before the runner has wired up its receiver) and during + /// shutdown (runner already torn down, late hook still draining). + /// Send failures (receiver dropped without `unregister`) are also + /// logged and the stale entry is evicted so the next event takes + /// the "no listener" path instead of repeatedly failing to send. + pub fn dispatch_interactive_hook(&self, event: InteractiveHookEvent) { + // Hold the lock long enough to send; the channel is unbounded + // so send is non-blocking. Evicting on send failure has to + // happen under the same lock to avoid racing with a fresh + // re-registration on the same sid. + let mut map = self + .interactive_hook_channels + .lock() + .expect("interactive_hook_channels poisoned"); + let sid = event.sid.clone(); + match map.get(&sid) { + Some(tx) => { + if let Err(err) = tx.send(event) { + tracing::warn!( + target: "claudette::interactive", + sid = %sid, + error = %err, + "interactive hook receiver dropped before unregister; evicting channel" + ); + map.remove(&sid); + } + } + None => { + tracing::warn!( + target: "claudette::interactive", + sid = %sid, + kind = ?event.kind, + "no interactive hook channel registered for sid; dropping event" + ); + } + } + } + /// Snapshot the plugin registry for use across `await` points. /// /// The lock is held only long enough to `Arc::clone` the inner From 37bbb3c2ddfc4364a501acae61bb25de4493a950 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:34:15 -0700 Subject: [PATCH 33/95] feat(agent): settings overlay materializes Claude Code hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `SettingsOverlay` in `src/agent/claude_interactive.rs` — a per-session transient `CLAUDE_CONFIG_DIR` directory that writes a `settings.json` registering three Claude Code hooks (Stop / Notification / UserPromptSubmit). Each hook invokes the bundled `claudette-cli` binary with `chat hook --sid --kind ` so events route back into the GUI via the IPC `chat_hook` method landed in E1+E2. The hook JSON shape mirrors `args::build_settings_json` — `hooks.[].matcher` plus a nested `hooks[]` list of `{type, command}` entries — so the overlay is interchangeable with Claudette's existing `-p`-mode hook injection. This is the initial skeleton for the ClaudeInteractive backend; the full `InteractiveSession` lands in F2. --- src/agent/claude_interactive.rs | 142 ++++++++++++++++++++++++++++++++ src/agent/mod.rs | 1 + 2 files changed, 143 insertions(+) create mode 100644 src/agent/claude_interactive.rs diff --git a/src/agent/claude_interactive.rs b/src/agent/claude_interactive.rs new file mode 100644 index 000000000..a741abdb9 --- /dev/null +++ b/src/agent/claude_interactive.rs @@ -0,0 +1,142 @@ +//! ClaudeInteractive backend. +//! +//! Materializes a per-session settings overlay that registers Claude Code +//! hooks, then asks an `InteractiveHost` to spawn `claude` with +//! `CLAUDE_CONFIG_DIR` pointing at the overlay. + +use std::path::{Path, PathBuf}; + +/// A transient `CLAUDE_CONFIG_DIR` overlay that registers three Claude Code +/// hooks (Stop / Notification / UserPromptSubmit) which call back into the +/// running GUI via the bundled `claudette-cli` binary. +/// +/// The overlay is per-session: every interactive session gets its own +/// directory keyed by `sid`, and its hook commands embed that same `sid` so +/// the IPC callback can route events to the right channel. +#[derive(Debug, Clone)] +pub struct SettingsOverlay { + /// Absolute path to the overlay directory. Suitable for use as + /// `CLAUDE_CONFIG_DIR` when spawning `claude`. + pub dir: PathBuf, +} + +impl SettingsOverlay { + /// Create a fresh per-session overlay directory and write `settings.json` + /// registering hooks that call back via `cli_bin_abs` with `--sid `. + /// + /// The hook schema matches the shape used by + /// `crate::agent::args::build_settings_json` — `hooks.[]` with + /// `matcher` plus a nested `hooks[]` list of `{type, command}` entries. + pub fn materialize(parent: &Path, sid: &str, cli_bin_abs: &Path) -> std::io::Result { + let dir = parent.join(sid).join("claude-config"); + std::fs::create_dir_all(&dir)?; + + let cli = shell_quote(cli_bin_abs.to_string_lossy().as_ref()); + let stop_cmd = format!("{cli} chat hook --sid {sid} --kind stop"); + let awaiting_cmd = format!("{cli} chat hook --sid {sid} --kind awaiting"); + let prompt_cmd = format!("{cli} chat hook --sid {sid} --kind prompt_submitted"); + + let settings = serde_json::json!({ + "hooks": { + "Stop": [{ + "matcher": "", + "hooks": [{ "type": "command", "command": stop_cmd }], + }], + "Notification": [{ + "matcher": "", + "hooks": [{ "type": "command", "command": awaiting_cmd }], + }], + "UserPromptSubmit": [{ + "matcher": "", + "hooks": [{ "type": "command", "command": prompt_cmd }], + }], + } + }); + std::fs::write( + dir.join("settings.json"), + serde_json::to_vec_pretty(&settings)?, + )?; + Ok(Self { dir }) + } + + /// Remove the overlay directory. Idempotent — returns Ok if already gone. + pub fn cleanup(&self) -> std::io::Result<()> { + if self.dir.exists() { + std::fs::remove_dir_all(&self.dir)?; + } + Ok(()) + } +} + +/// POSIX-style shell quoting for the hook command. A bare path that only +/// contains alphanumerics plus `/ _ - .` is left as-is; anything else is +/// single-quoted with embedded `'` escaped as `'\''`. +fn shell_quote(s: &str) -> String { + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.')) + { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_writes_settings_with_three_hooks() { + let dir = tempfile::tempdir().unwrap(); + let overlay = SettingsOverlay::materialize( + dir.path(), + "claudette-x-y", + Path::new("/abs/path/to/claudette-cli"), + ) + .unwrap(); + let json: serde_json::Value = + serde_json::from_slice(&std::fs::read(overlay.dir.join("settings.json")).unwrap()) + .unwrap(); + let hooks = json.get("hooks").unwrap(); + for key in ["Stop", "Notification", "UserPromptSubmit"] { + assert!(hooks.get(key).is_some(), "missing hook: {key}"); + let arr = hooks.get(key).unwrap().as_array().unwrap(); + assert_eq!(arr.len(), 1, "hook {key} should have one matcher entry"); + let entry = &arr[0]; + assert_eq!(entry.get("matcher").and_then(|v| v.as_str()), Some("")); + let inner = entry.get("hooks").unwrap().as_array().unwrap(); + assert_eq!(inner.len(), 1); + assert_eq!( + inner[0].get("type").and_then(|v| v.as_str()), + Some("command") + ); + let cmd = inner[0].get("command").and_then(|v| v.as_str()).unwrap(); + assert!(cmd.contains("/abs/path/to/claudette-cli"), "cmd: {cmd}"); + assert!(cmd.contains("--sid claudette-x-y"), "cmd: {cmd}"); + assert!(cmd.contains("chat hook"), "cmd: {cmd}"); + } + overlay.cleanup().unwrap(); + assert!(!overlay.dir.exists()); + } + + #[test] + fn shell_quote_leaves_plain_paths_alone() { + assert_eq!( + shell_quote("/usr/local/bin/claudette-cli"), + "/usr/local/bin/claudette-cli" + ); + } + + #[test] + fn shell_quote_wraps_paths_with_spaces() { + assert_eq!( + shell_quote("/Users/me/Application Support/cli"), + "'/Users/me/Application Support/cli'" + ); + } + + #[test] + fn shell_quote_escapes_embedded_single_quotes() { + assert_eq!(shell_quote("foo'bar"), "'foo'\\''bar'"); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index ba1fb5dce..56f22f45e 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,6 +1,7 @@ mod args; pub mod background; mod binary; +pub mod claude_interactive; pub mod codex_app_server; mod environment; pub mod harness; From 2b29b29a7a125bc969296ee5fed889e78153d2c0 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:47:35 -0700 Subject: [PATCH 34/95] feat(agent): add ClaudeInteractive harness variant and backend resolver branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the F1 harness-layer scaffolding for the interactive `claude` backend: a new `AgentHarnessKind::ClaudeInteractive` variant, an `AgentHarnessCapabilities::claude_interactive()` preset (persistent sessions + MCP, but no steering / host permissions / remote control / rich attachments yet), a stub `InteractiveSession` struct, and matching `AgentSession::ClaudeInteractive` enum arm with placeholder implementations of the per-method match arms (turn dispatch, steering, subscribe, control responses, task stop, interrupt, remote control) — F2 will replace those with real plumbing. Adds `AgentBackendRuntimeHarness::ClaudeInteractive` so the user-facing runtime selection can carry the choice, and a flag-gated resolver helper `AgentBackendConfig::effective_harness_kind(claude_interactive_enabled)` that maps the persisted runtime to an `AgentHarnessKind`. When the `claudeInteractiveEnabled` experimental flag is off, an attempted override falls back to ClaudeCode rather than dispatch into a disabled experiment. Updates the exhaustive `AgentBackendRuntimeHarness` matches in `runtime_dispatch.rs`, `chat/send.rs`, `chat/remote_control.rs`, and the `harness_hash_key` / `harness_serde_name` helpers so the build stays green; the new arms error out for now and will be wired in F2. --- .../src/commands/agent_backends/config.rs | 1 + .../agent_backends/runtime_dispatch.rs | 10 +++ src-tauri/src/commands/chat/remote_control.rs | 1 + src-tauri/src/commands/chat/send.rs | 16 ++++ src/agent/claude_interactive.rs | 15 ++++ src/agent/harness.rs | 83 +++++++++++++++++++ src/agent_backend.rs | 51 ++++++++++++ 7 files changed, 177 insertions(+) diff --git a/src-tauri/src/commands/agent_backends/config.rs b/src-tauri/src/commands/agent_backends/config.rs index 86aa2b3c2..913cf1dbb 100644 --- a/src-tauri/src/commands/agent_backends/config.rs +++ b/src-tauri/src/commands/agent_backends/config.rs @@ -551,6 +551,7 @@ pub(super) fn backend_kind_hash_key(kind: AgentBackendKind) -> &'static str { fn harness_hash_key(harness: AgentBackendRuntimeHarness) -> &'static str { match harness { AgentBackendRuntimeHarness::ClaudeCode => "claude_code", + AgentBackendRuntimeHarness::ClaudeInteractive => "claude_interactive", AgentBackendRuntimeHarness::CodexAppServer => "codex_app_server", #[cfg(feature = "pi-sdk")] AgentBackendRuntimeHarness::PiSdk => "pi_sdk", diff --git a/src-tauri/src/commands/agent_backends/runtime_dispatch.rs b/src-tauri/src/commands/agent_backends/runtime_dispatch.rs index 7909bc544..8a6e7176b 100644 --- a/src-tauri/src/commands/agent_backends/runtime_dispatch.rs +++ b/src-tauri/src/commands/agent_backends/runtime_dispatch.rs @@ -99,6 +99,16 @@ pub async fn resolve_backend_runtime( AgentBackendRuntimeHarness::ClaudeCode => { build_claude_code_runtime(state, &mut backend, model).await } + // F1 only wires the variant through the harness layer; the + // runtime build for interactive sessions arrives in a later + // task. `resolve_dispatch_harness` will never surface this + // value today (no `AgentBackendKind::available_harnesses()` + // listing includes `ClaudeInteractive`), so the unreachable + // branch is defense-in-depth against a future broadening that + // forgets to add the runtime builder. + AgentBackendRuntimeHarness::ClaudeInteractive => { + Err("ClaudeInteractive runtime build is not implemented yet".to_string()) + } } } diff --git a/src-tauri/src/commands/chat/remote_control.rs b/src-tauri/src/commands/chat/remote_control.rs index dc95c6930..0932652d5 100644 --- a/src-tauri/src/commands/chat/remote_control.rs +++ b/src-tauri/src/commands/chat/remote_control.rs @@ -303,6 +303,7 @@ fn unsupported_remote_control_session_error(ps: &AgentSession) -> String { fn remote_control_requires_claude_cli_message(harness: AgentBackendRuntimeHarness) -> String { let harness_label = match harness { AgentBackendRuntimeHarness::ClaudeCode => "Claude CLI", + AgentBackendRuntimeHarness::ClaudeInteractive => "Claude Interactive", AgentBackendRuntimeHarness::CodexAppServer => "Codex app-server", #[cfg(feature = "pi-sdk")] AgentBackendRuntimeHarness::PiSdk => "Pi SDK", diff --git a/src-tauri/src/commands/chat/send.rs b/src-tauri/src/commands/chat/send.rs index 1646f4b85..2cb691517 100644 --- a/src-tauri/src/commands/chat/send.rs +++ b/src-tauri/src/commands/chat/send.rs @@ -2122,6 +2122,13 @@ pub async fn send_chat_message( .await?; Ok::, String>(Arc::new(AgentSession::from_pi_sdk(started))) } + // F1 wires the variant through the harness layer only; + // `resolve_backend_runtime` never produces this value + // today, so the spawn site rejects it loudly. The real + // start path lives in F2. + AgentBackendRuntimeHarness::ClaudeInteractive => Err::, String>( + "ClaudeInteractive session start is not implemented yet".to_string(), + ), } } }; @@ -2198,6 +2205,10 @@ pub async fn send_chat_message( AgentBackendRuntimeHarness::CodexAppServer => None, #[cfg(feature = "pi-sdk")] AgentBackendRuntimeHarness::PiSdk => None, + // F1 stub: interactive sessions don't run through + // the print-mode hook bridge — they get hooks via + // the per-session `CLAUDE_CONFIG_DIR` overlay. + AgentBackendRuntimeHarness::ClaudeInteractive => None, }; if let Some(bridge) = bridge.as_ref() && matches!( @@ -2385,6 +2396,11 @@ pub async fn send_chat_message( AgentBackendRuntimeHarness::CodexAppServer => None, #[cfg(feature = "pi-sdk")] AgentBackendRuntimeHarness::PiSdk => None, + // F1 stub: interactive sessions deliver hook callbacks + // through the per-session settings overlay materialized in + // `claude_interactive::SettingsOverlay`, not the print-mode + // chat bridge. + AgentBackendRuntimeHarness::ClaudeInteractive => None, }; if let Some(bridge) = bridge.as_ref() && matches!( diff --git a/src/agent/claude_interactive.rs b/src/agent/claude_interactive.rs index a741abdb9..9d7b59fdb 100644 --- a/src/agent/claude_interactive.rs +++ b/src/agent/claude_interactive.rs @@ -6,6 +6,21 @@ use std::path::{Path, PathBuf}; +/// Long-lived handle to an interactive `claude` session running inside an +/// `InteractiveHost` (tmux on Unix, sidecar elsewhere). +/// +/// This is a stub for the F1 harness wiring. The real `start()` / +/// `send_turn` / `interrupt` plumbing lands in F2 — for now the variant +/// only needs to compile and route through [`crate::agent::AgentSession`] +/// so the resolver layer can be exercised end-to-end. +#[derive(Debug, Clone)] +pub struct InteractiveSession { + /// Stable session identifier (`claudette--`). Mirrors + /// the `sid` embedded in the settings-overlay hook commands so that + /// `claudette-cli chat hook` callbacks route back to this session. + pub sid: String, +} + /// A transient `CLAUDE_CONFIG_DIR` overlay that registers three Claude Code /// hooks (Stop / Notification / UserPromptSubmit) which call back into the /// running GUI via the bundled `claudette-cli` binary. diff --git a/src/agent/harness.rs b/src/agent/harness.rs index dc3d35f8b..bb1cd1a5f 100644 --- a/src/agent/harness.rs +++ b/src/agent/harness.rs @@ -3,6 +3,7 @@ use std::path::Path; use crate::env::WorkspaceEnv; use crate::env_provider::ResolvedEnv; +use super::claude_interactive::InteractiveSession; use super::codex_app_server::CodexAppServerSession; #[cfg(feature = "pi-sdk")] use super::pi_sdk::PiSdkSession; @@ -15,6 +16,10 @@ use super::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentHarnessKind { ClaudeCode, + /// Interactive `claude` session running inside a detachable host + /// (tmux on Unix, sidecar elsewhere). Gated at resolve time on the + /// `claudeInteractiveEnabled` experimental flag. + ClaudeInteractive, CodexAppServer, #[cfg(feature = "pi-sdk")] PiSdk, @@ -55,6 +60,26 @@ impl AgentHarnessCapabilities { } } + /// Capabilities preset for the interactive `claude` harness. + /// + /// v1 mirrors the Claude Code feature set on session persistence and + /// MCP configuration, but disables mid-turn steering (the TUI owns + /// its own input buffer), host-side permission prompts (claude + /// handles them natively in its TUI), remote control, and rich + /// attachments — pasted file content has to round-trip through the + /// terminal until a follow-up task adds first-class attachment + /// forwarding. + pub const fn claude_interactive() -> Self { + Self { + persistent_sessions: true, + steer_turn: false, + host_permission_prompts: false, + remote_control: false, + mcp_config: true, + attachments: false, + } + } + #[cfg(feature = "pi-sdk")] pub const fn pi_sdk() -> Self { Self { @@ -115,6 +140,11 @@ impl ClaudeCodeHarness { /// still the only production variant. pub enum AgentSession { ClaudeCode(PersistentSession), + /// Interactive `claude` session running inside an `InteractiveHost`. + /// The F1 variant only carries the stub `InteractiveSession`; the + /// per-method plumbing (turn dispatch, steering, subscribe, etc.) + /// lands in F2. + ClaudeInteractive(InteractiveSession), CodexAppServer(CodexAppServerSession), #[cfg(feature = "pi-sdk")] PiSdk(PiSdkSession), @@ -125,6 +155,13 @@ impl AgentSession { Self::ClaudeCode(session) } + /// Wrap an `InteractiveSession` in the harness-agnostic enum so the + /// chat dispatcher can route to the interactive harness alongside + /// the other variants. The body of the variant stays a stub until F2. + pub fn from_claude_interactive(session: InteractiveSession) -> Self { + Self::ClaudeInteractive(session) + } + pub fn from_codex_app_server(session: CodexAppServerSession) -> Self { Self::CodexAppServer(session) } @@ -137,6 +174,7 @@ impl AgentSession { pub fn kind(&self) -> AgentHarnessKind { match self { Self::ClaudeCode(_) => AgentHarnessKind::ClaudeCode, + Self::ClaudeInteractive(_) => AgentHarnessKind::ClaudeInteractive, Self::CodexAppServer(_) => AgentHarnessKind::CodexAppServer, #[cfg(feature = "pi-sdk")] Self::PiSdk(_) => AgentHarnessKind::PiSdk, @@ -146,6 +184,7 @@ impl AgentSession { pub fn capabilities(&self) -> AgentHarnessCapabilities { match self { Self::ClaudeCode(_) => AgentHarnessCapabilities::claude_code(), + Self::ClaudeInteractive(_) => AgentHarnessCapabilities::claude_interactive(), Self::CodexAppServer(_) => AgentHarnessCapabilities::codex_app_server(), #[cfg(feature = "pi-sdk")] Self::PiSdk(_) => AgentHarnessCapabilities::pi_sdk(), @@ -155,6 +194,12 @@ impl AgentSession { pub fn pid(&self) -> u32 { match self { Self::ClaudeCode(session) => session.pid(), + // The interactive stub has no spawned process yet — F2 wires + // the host-owned PID through. Returning 0 keeps the function + // total without lying to callers that perform process + // operations on the value (they should branch on `kind()` + // first). + Self::ClaudeInteractive(_) => 0, Self::CodexAppServer(session) => session.pid(), #[cfg(feature = "pi-sdk")] Self::PiSdk(session) => session.pid(), @@ -173,6 +218,9 @@ impl AgentSession { .send_turn_with_uuid(prompt, attachments, user_message_uuid) .await } + Self::ClaudeInteractive(_) => { + Err("ClaudeInteractive turn dispatch is not implemented yet (F2)".to_string()) + } Self::CodexAppServer(session) => session.send_turn(prompt, attachments).await, #[cfg(feature = "pi-sdk")] Self::PiSdk(session) => session.send_turn(prompt, attachments).await, @@ -186,6 +234,11 @@ impl AgentSession { ) -> Result<(), String> { match self { Self::ClaudeCode(session) => session.steer_user_message(prompt, attachments).await, + // Steering is intentionally unsupported in the interactive + // harness; the capabilities preset advertises this. + Self::ClaudeInteractive(_) => { + Err("ClaudeInteractive does not support mid-turn steering".to_string()) + } Self::CodexAppServer(session) => session.steer_turn(prompt, attachments).await, #[cfg(feature = "pi-sdk")] Self::PiSdk(session) => session.steer_turn(prompt, attachments).await, @@ -195,6 +248,14 @@ impl AgentSession { pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { match self { Self::ClaudeCode(session) => session.subscribe(), + // Stub: hand back a receiver of an immediately-closed channel + // so callers that subscribe before F2 wires the real bridge + // observe a clean `Closed` instead of panicking. The dropped + // sender is intentional. + Self::ClaudeInteractive(_) => { + let (_tx, rx) = tokio::sync::broadcast::channel(1); + rx + } Self::CodexAppServer(session) => session.subscribe(), #[cfg(feature = "pi-sdk")] Self::PiSdk(session) => session.subscribe(), @@ -208,6 +269,9 @@ impl AgentSession { ) -> Result<(), String> { match self { Self::ClaudeCode(session) => session.send_control_response(request_id, response).await, + Self::ClaudeInteractive(_) => Err(format!( + "ClaudeInteractive does not handle host-side control requests (id `{request_id}`)", + )), Self::CodexAppServer(session) => { session.send_control_response(request_id, response).await } @@ -219,6 +283,9 @@ impl AgentSession { pub async fn send_task_stop(&self, task_id: &str) -> Result<(), String> { match self { Self::ClaudeCode(session) => session.send_task_stop(task_id).await, + Self::ClaudeInteractive(_) => Err(format!( + "ClaudeInteractive harness cannot stop task `{task_id}` yet" + )), Self::CodexAppServer(_) => { Err(format!("Codex app-server cannot stop task `{task_id}` yet")) } @@ -230,6 +297,9 @@ impl AgentSession { pub async fn interrupt_turn(&self) -> Result<(), String> { match self { Self::ClaudeCode(session) => super::process::stop_agent(session.pid()).await, + Self::ClaudeInteractive(_) => { + Err("ClaudeInteractive interrupt is not implemented yet (F2)".to_string()) + } Self::CodexAppServer(session) => session.interrupt_turn().await, #[cfg(feature = "pi-sdk")] Self::PiSdk(session) => session.interrupt_turn().await, @@ -269,6 +339,9 @@ impl AgentSession { ) -> Result { match self { Self::ClaudeCode(session) => session.set_remote_control(enabled).await, + Self::ClaudeInteractive(_) => { + Err("ClaudeInteractive does not support Claude Remote Control".to_string()) + } Self::CodexAppServer(_) => { Err("Codex app-server does not support Claude Remote Control".to_string()) } @@ -300,6 +373,16 @@ mod tests { ); } + #[test] + fn claude_interactive_capabilities() { + let c = AgentHarnessCapabilities::claude_interactive(); + assert!(c.persistent_sessions); + assert!( + !c.attachments, + "interactive mode does not yet support rich attachments" + ); + } + #[test] fn codex_app_server_capabilities_are_harness_specific() { assert_eq!( diff --git a/src/agent_backend.rs b/src/agent_backend.rs index 62c9a9002..3d34032e5 100644 --- a/src/agent_backend.rs +++ b/src/agent_backend.rs @@ -136,6 +136,11 @@ impl AgentBackendKind { pub enum AgentBackendRuntimeHarness { #[default] ClaudeCode, + /// Interactive `claude` running inside an `InteractiveHost` (tmux on + /// Unix, sidecar elsewhere). Gated on the `claudeInteractiveEnabled` + /// experimental flag — see + /// [`AgentBackendConfig::effective_harness_kind`]. + ClaudeInteractive, CodexAppServer, #[cfg(feature = "pi-sdk")] PiSdk, @@ -242,6 +247,51 @@ impl AgentBackendConfig { _ => self.kind.default_harness(), } } + + /// Map the persisted runtime selection onto the internal + /// [`crate::agent::AgentHarnessKind`] the chat dispatcher uses to + /// pick a session-protocol implementation. + /// + /// `ClaudeInteractive` is special: it's gated on the experimental + /// `claudeInteractiveEnabled` flag and is intentionally absent from + /// `available_harnesses()` (which keeps the UI runtime selector + /// honest for non-experimental users). The resolver therefore reads + /// `runtime_harness` directly here — bypassing the + /// `available_harnesses` filter — but only when the flag is on. If + /// the flag is off and the user has somehow pinned + /// `ClaudeInteractive` (downgrade, hand-edited DB, etc.), we fall + /// back to the kind's default harness rather than dispatch into a + /// disabled experiment. + pub fn effective_harness_kind( + &self, + claude_interactive_enabled: bool, + ) -> crate::agent::AgentHarnessKind { + if claude_interactive_enabled + && matches!( + self.runtime_harness, + Some(AgentBackendRuntimeHarness::ClaudeInteractive) + ) + { + return crate::agent::AgentHarnessKind::ClaudeInteractive; + } + match self.effective_harness() { + AgentBackendRuntimeHarness::ClaudeCode => crate::agent::AgentHarnessKind::ClaudeCode, + AgentBackendRuntimeHarness::ClaudeInteractive => { + // `effective_harness()` is filtered by `available_harnesses()`, + // which never lists `ClaudeInteractive` today, so this + // arm is unreachable in production. Keep it explicit + // (over `unreachable!()`) so a future broadening of the + // allow-list doesn't silently panic — falling back to + // ClaudeCode matches the gate-off behavior above. + crate::agent::AgentHarnessKind::ClaudeCode + } + AgentBackendRuntimeHarness::CodexAppServer => { + crate::agent::AgentHarnessKind::CodexAppServer + } + #[cfg(feature = "pi-sdk")] + AgentBackendRuntimeHarness::PiSdk => crate::agent::AgentHarnessKind::PiSdk, + } + } } impl AgentBackendConfig { @@ -749,6 +799,7 @@ mod tests { fn harness_serde_name(harness: AgentBackendRuntimeHarness) -> &'static str { match harness { AgentBackendRuntimeHarness::ClaudeCode => "claude_code", + AgentBackendRuntimeHarness::ClaudeInteractive => "claude_interactive", AgentBackendRuntimeHarness::CodexAppServer => "codex_app_server", AgentBackendRuntimeHarness::PiSdk => "pi_sdk", } From e6d8a7a2a9e9be0761d8a0c6419e9938a443d81c Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:53:54 -0700 Subject: [PATCH 35/95] feat(agent): InteractiveSession::start with host selection Replace the F1 `InteractiveSession` stub with a real `start` constructor that materializes a per-session `SettingsOverlay`, points the `SessionSpec::claude_config_dir` at it, and asks the supplied `InteractiveHost` to `ensure_session`. The returned handle now carries the host `Arc` and the overlay so later turn-dispatch / cleanup wiring can reach both without re-resolving. Adds `select_default_host` in `interactive_host::mod` to pick the platform-appropriate host: tmux on Unix when `tmux >= 3.0` is on PATH and the caller hasn't asked for the sidecar, otherwise the sidecar host backed by the `claudette-session-host` binary. Session ids use the form `claudette--<8 hex chars>` via a small `random_hex8` helper backed by `rand::thread_rng().fill_bytes`, matching the established pattern in `agent_mcp::bridge`. --- src/agent/claude_interactive.rs | 75 ++++++++++++++++++++++++++++--- src/agent/interactive_host/mod.rs | 35 +++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/agent/claude_interactive.rs b/src/agent/claude_interactive.rs index 9d7b59fdb..cc97e46cb 100644 --- a/src/agent/claude_interactive.rs +++ b/src/agent/claude_interactive.rs @@ -5,20 +5,74 @@ //! `CLAUDE_CONFIG_DIR` pointing at the overlay. use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::agent::interactive_host::{HostError, InteractiveHost, SessionId, SessionSpec}; /// Long-lived handle to an interactive `claude` session running inside an /// `InteractiveHost` (tmux on Unix, sidecar elsewhere). /// -/// This is a stub for the F1 harness wiring. The real `start()` / -/// `send_turn` / `interrupt` plumbing lands in F2 — for now the variant -/// only needs to compile and route through [`crate::agent::AgentSession`] -/// so the resolver layer can be exercised end-to-end. -#[derive(Debug, Clone)] +/// Holds the stable session id, a shared handle to the host that owns the +/// session, and the per-session settings overlay (so callers can clean it +/// up when the session is torn down). The actual turn dispatch / steering +/// plumbing lands in subsequent tasks — this type's job for F2 is to bind +/// the three pieces together at construction time. pub struct InteractiveSession { /// Stable session identifier (`claudette--`). Mirrors /// the `sid` embedded in the settings-overlay hook commands so that /// `claudette-cli chat hook` callbacks route back to this session. pub sid: String, + /// The interactive host that owns the live session. Shared with the + /// rest of the app so attach / send_input / stop can be routed + /// through the same handle that spawned the session. + pub host: Arc, + /// Per-session `CLAUDE_CONFIG_DIR` overlay. Owned by the session so + /// the directory is cleaned up alongside the session itself. + pub overlay: SettingsOverlay, +} + +impl InteractiveSession { + /// Spin up a new interactive session: pick a session id, materialize + /// a settings overlay, point `SessionSpec::claude_config_dir` at it, + /// and ask the host to `ensure_session`. + /// + /// `overlay_parent` is the directory under which the per-session + /// overlay subtree (`/claude-config/settings.json`) is written. + /// `cli_bin_abs` is the absolute path to the `claudette-cli` binary + /// that the hooks will shell out to — kept as an argument (rather + /// than resolved internally) so dev / test callers can swap in a + /// stub. + pub async fn start( + workspace_short: &str, + host: Arc, + spec: SessionSpec, + overlay_parent: &Path, + cli_bin_abs: &Path, + ) -> Result { + let sid_str = format!("claudette-{}-{}", workspace_short, random_hex8()); + let overlay = SettingsOverlay::materialize(overlay_parent, &sid_str, cli_bin_abs) + .map_err(|e| HostError::Other(e.to_string()))?; + let spec = SessionSpec { + claude_config_dir: overlay.dir.to_string_lossy().into_owned(), + ..spec + }; + let sid = SessionId(sid_str.clone()); + host.ensure_session(&sid, &spec).await?; + Ok(Self { + sid: sid_str, + host, + overlay, + }) + } +} + +/// 4 cryptographically-random bytes formatted as 8 lowercase hex chars. +/// Used to disambiguate sessions within a single workspace. +fn random_hex8() -> String { + use rand::RngCore; + let mut buf = [0u8; 4]; + rand::thread_rng().fill_bytes(&mut buf); + buf.iter().map(|b| format!("{b:02x}")).collect() } /// A transient `CLAUDE_CONFIG_DIR` overlay that registers three Claude Code @@ -154,4 +208,15 @@ mod tests { fn shell_quote_escapes_embedded_single_quotes() { assert_eq!(shell_quote("foo'bar"), "'foo'\\''bar'"); } + + #[test] + fn random_hex8_returns_8_lowercase_hex_chars() { + let s = random_hex8(); + assert_eq!(s.len(), 8, "expected 8 chars, got {s:?}"); + assert!( + s.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()), + "expected lowercase hex digits, got {s:?}" + ); + } } diff --git a/src/agent/interactive_host/mod.rs b/src/agent/interactive_host/mod.rs index 8e72b799b..1b894ad6b 100644 --- a/src/agent/interactive_host/mod.rs +++ b/src/agent/interactive_host/mod.rs @@ -48,3 +48,38 @@ pub enum HostError { #[error("other: {0}")] Other(String), } + +/// Pick the default `InteractiveHost` for the current platform. +/// +/// On Unix, prefers a tmux-backed host when `tmux >= 3.0` is on `PATH` and +/// the caller hasn't asked for the sidecar explicitly (`prefer_sidecar_on_unix +/// = false`). On Windows, or when tmux is missing / too old, falls back to +/// the sidecar host backed by `sidecar_binary` over `sidecar_socket`. +pub async fn select_default_host( + runtime_dir: &std::path::Path, + sidecar_socket: &std::path::Path, + sidecar_binary: &std::path::Path, + prefer_sidecar_on_unix: bool, +) -> Result, HostError> { + // Silence unused-arg warnings on Windows, where the tmux path is + // unreachable. The sidecar branch consumes the other two. + #[cfg(not(unix))] + let _ = (runtime_dir, prefer_sidecar_on_unix); + + #[cfg(unix)] + if !prefer_sidecar_on_unix + && matches!( + availability::check_tmux().await, + availability::TmuxAvailability::Available { .. } + ) + { + return Ok(std::sync::Arc::new(tmux::TmuxHost::new( + runtime_dir.to_path_buf(), + ))); + } + + Ok(std::sync::Arc::new(sidecar::SidecarHost::new( + sidecar_socket.to_path_buf(), + sidecar_binary.to_path_buf(), + ))) +} From b4737e7a76a06668d255a5772e1742f8260be690 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 21:56:37 -0700 Subject: [PATCH 36/95] fix(agent): use platform-correct shell quoting for hook commands --- src/agent/claude_interactive.rs | 74 +++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/src/agent/claude_interactive.rs b/src/agent/claude_interactive.rs index cc97e46cb..e99afd5cd 100644 --- a/src/agent/claude_interactive.rs +++ b/src/agent/claude_interactive.rs @@ -137,16 +137,47 @@ impl SettingsOverlay { } } -/// POSIX-style shell quoting for the hook command. A bare path that only -/// contains alphanumerics plus `/ _ - .` is left as-is; anything else is -/// single-quoted with embedded `'` escaped as `'\''`. +/// Platform-correct shell quoting for the hook command. +/// +/// On Unix, hooks are invoked via `sh -c`, so POSIX single-quote escaping is +/// used: a bare path that only contains alphanumerics plus `/ _ - .` is left +/// as-is; anything else is single-quoted with embedded `'` escaped as `'\''`. +/// +/// On Windows, hooks are invoked via `cmd.exe /S /C`, which does not treat +/// single quotes as quoting delimiters. Use double-quote escaping instead: a +/// bare path that only contains alphanumerics plus `/ \ _ - . :` is left +/// as-is; anything else is wrapped in double quotes with embedded `"` +/// doubled (cmd.exe's escape convention). fn shell_quote(s: &str) -> String { - if s.chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.')) + #[cfg(unix)] + { + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.')) + { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } + } + #[cfg(windows)] { - s.to_string() - } else { - format!("'{}'", s.replace('\'', "'\\''")) + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '\\' | '_' | '-' | '.' | ':')) + { + s.to_string() + } else { + format!("\"{}\"", s.replace('"', "\"\"")) + } + } + #[cfg(not(any(unix, windows)))] + { + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.')) + { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } } } @@ -188,6 +219,7 @@ mod tests { assert!(!overlay.dir.exists()); } + #[cfg(unix)] #[test] fn shell_quote_leaves_plain_paths_alone() { assert_eq!( @@ -196,6 +228,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn shell_quote_wraps_paths_with_spaces() { assert_eq!( @@ -204,11 +237,36 @@ mod tests { ); } + #[cfg(unix)] #[test] fn shell_quote_escapes_embedded_single_quotes() { assert_eq!(shell_quote("foo'bar"), "'foo'\\''bar'"); } + #[cfg(windows)] + #[test] + fn shell_quote_windows_wraps_path_with_spaces_in_double_quotes() { + assert_eq!( + shell_quote("C:\\Program Files\\claudette-cli.exe"), + "\"C:\\Program Files\\claudette-cli.exe\"" + ); + } + + #[cfg(windows)] + #[test] + fn shell_quote_windows_doubles_embedded_double_quotes() { + assert_eq!(shell_quote("foo\"bar"), "\"foo\"\"bar\""); + } + + #[cfg(windows)] + #[test] + fn shell_quote_windows_leaves_plain_paths_alone() { + assert_eq!( + shell_quote("C:\\Tools\\claudette-cli.exe"), + "C:\\Tools\\claudette-cli.exe" + ); + } + #[test] fn random_hex8_returns_8_lowercase_hex_chars() { let s = random_hex8(); From 390130bc1c24ea1ffd0f4d6de7f89b480106d2ad Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:08:46 -0700 Subject: [PATCH 37/95] feat(tauri): interactive_start/send_input/attach/capture_screen/stop commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the Tauri command surface for the experimental Claude Interactive backend (F3 of the interactive-claude plan): - New `commands::interactive` module exposing `interactive_start`, `interactive_send_input`, `interactive_capture_screen`, `interactive_stop`, `interactive_list_for_workspace`, and `interactive_attach`. All gated on the `claudeInteractiveEnabled` experimental flag. - `interactive_attach` spawns a Tokio task that subscribes to the host's `AttachStream` and forwards events to the frontend as `interactive:///{output,hook,exit,error}` Tauri events. - `AppState` gains the helpers the commands need: cached `Arc` keyed by workspace, a sid → workspace_id reverse index, and host/runtime/CLI-binary resolvers. Host kind derives from the existing tmux availability probe. Capture/stop tolerate `QueryReturnedNoRows` on the DB update so a partially torn-down session doesn't fail the command; sid is dropped from the index on stop regardless of host outcome. F4 will harden the bundled CLI binary path resolution. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 1 + src-tauri/Cargo.toml | 4 + src-tauri/src/commands/interactive.rs | 471 ++++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/main.rs | 7 + src-tauri/src/state.rs | 167 +++++++++ 6 files changed, 651 insertions(+) create mode 100644 src-tauri/src/commands/interactive.rs diff --git a/Cargo.lock b/Cargo.lock index c00868ed1..a15e7ebec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1029,6 +1029,7 @@ dependencies = [ "tokenizers", "tokio", "tokio-rustls", + "tokio-stream", "tokio-tungstenite", "tracing", "tracing-subscriber", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cf41e6eaf..33af21519 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,10 @@ futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["sync", "time", "process", "fs", "rt"] } +# Used by `commands::interactive` to drive the InteractiveHost +# AttachStream forwarder. Same version unification as the lib crate +# uses for the `Stream` trait re-export. +tokio-stream = "0.1" uuid = { version = "1", features = ["v4"] } dirs = "6" libc = "0.2" diff --git a/src-tauri/src/commands/interactive.rs b/src-tauri/src/commands/interactive.rs new file mode 100644 index 000000000..525cddb93 --- /dev/null +++ b/src-tauri/src/commands/interactive.rs @@ -0,0 +1,471 @@ +//! Tauri commands for the Claude (Interactive) experimental backend. +//! +//! These commands surface the [`claudette::agent::claude_interactive`] +//! plumbing built in tasks F1 + F2 to the frontend. They are thin +//! wrappers: they verify the experimental flag, resolve the cached +//! `InteractiveHost` for the workspace, route the call to the host / +//! session helpers, and persist state changes through the +//! `interactive_sessions` table. +//! +//! Routing: +//! - `interactive_start` picks a host via +//! [`AppState::interactive_host_for`], spawns the session, writes a +//! matching `interactive_sessions` row, and remembers the +//! `sid → workspace_id` mapping so per-session calls can find the +//! host without the frontend passing `workspace_id` every time. +//! - `interactive_send_input` / `interactive_capture_screen` / +//! `interactive_stop` look the session up by `sid` and forward to +//! the host's trait methods. +//! - `interactive_attach` runs a Tokio task that subscribes to the +//! host's `AttachStream` and forwards events as Tauri events +//! (`interactive:///output`, `interactive:///hook`, +//! `interactive:///exit`, `interactive:///error`). +//! +//! All commands return `Result<_, String>` to match the existing +//! Tauri command convention; the underlying typed errors are flattened +//! to their `Display` form so the React side can render them. + +use std::sync::Arc; + +use base64::Engine as _; +use claudette::agent::claude_interactive::InteractiveSession; +use claudette::agent::interactive_host::{ + AttachEvent, HostError, InteractiveHost, ScreenSnapshot, SessionId, +}; +use claudette::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; +use claudette::db::{Database, InteractiveSessionRow}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio_stream::StreamExt; + +use crate::state::AppState; + +/// Arguments for [`interactive_start`]. Mirrors +/// [`SessionSpec`](claudette::agent::interactive_protocol::SessionSpec) +/// closely but omits the `env` and `claude_config_dir` fields — F3 hard- +/// wires an empty env list and lets `InteractiveSession::start` +/// materialize the per-session overlay directory. +#[derive(Debug, Deserialize)] +pub struct StartInteractiveArgs { + pub workspace_id: String, + pub working_dir: String, + pub rows: u16, + pub cols: u16, + pub claude_binary: String, + pub claude_args: Vec, +} + +/// Return shape for [`interactive_start`]. `host_kind` is `"tmux"` +/// when the active host is the tmux-backed implementation and +/// `"sidecar"` otherwise; the frontend uses this string verbatim as +/// the persisted `interactive_sessions.host_kind` value. +#[derive(Debug, Serialize)] +pub struct StartInteractiveResult { + pub sid: String, + pub host_kind: String, +} + +/// Wire shape for the persisted `interactive_sessions` row returned +/// by [`interactive_list_for_workspace`]. Mirrors +/// [`InteractiveSessionRow`] directly; using a separate frontend type +/// keeps the wire surface stable if the DB row grows columns the +/// frontend doesn't need. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InteractiveSessionListItem { + pub sid: String, + pub workspace_id: String, + pub host_kind: String, + pub state: String, + pub crash_reason: Option, + pub created_at: String, + pub last_attached_at: Option, + pub claude_flags_json: String, + pub pid: Option, +} + +impl From for InteractiveSessionListItem { + fn from(row: InteractiveSessionRow) -> Self { + Self { + sid: row.sid, + workspace_id: row.workspace_id, + host_kind: row.host_kind, + state: row.state, + crash_reason: row.crash_reason, + created_at: row.created_at, + last_attached_at: row.last_attached_at, + claude_flags_json: row.claude_flags_json, + pid: row.pid, + } + } +} + +/// Truncate a workspace UUID to its first 8 chars for use as the +/// `` segment of an interactive session id. Returns +/// the input unchanged when it is already shorter than 8 chars (e.g. +/// test ids), so the resulting `claudette--` string +/// stays meaningful in logs. +fn workspace_short(workspace_id: &str) -> &str { + if workspace_id.len() <= 8 { + workspace_id + } else { + &workspace_id[..8] + } +} + +/// Spin up a fresh interactive `claude` session for the given +/// workspace. Persists the resulting row in `interactive_sessions` +/// with state `"running"` and registers the sid in the +/// sid→workspace_id index. +#[tauri::command] +pub async fn interactive_start( + state: State<'_, AppState>, + args: StartInteractiveArgs, +) -> Result { + if !state.claude_interactive_enabled().await { + return Err("Claude Interactive is disabled".into()); + } + let host = state + .interactive_host_for(&args.workspace_id) + .await + .map_err(|e| e.to_string())?; + let overlay_parent = state.runtime_dir_for_interactive().await; + let cli_bin = state + .bundled_cli_binary_path() + .await + .ok_or_else(|| "claudette-cli binary not found".to_string())?; + let host_kind = state + .interactive_host_kind_for(&args.workspace_id) + .await + .to_string(); + let claude_args_json = serde_json::to_string(&args.claude_args).map_err(|e| e.to_string())?; + let workspace_id = args.workspace_id.clone(); + let spec = SessionSpec { + working_dir: args.working_dir, + rows: args.rows, + cols: args.cols, + claude_binary: args.claude_binary, + claude_args: args.claude_args, + env: vec![], + claude_config_dir: String::new(), + }; + let sess = InteractiveSession::start( + workspace_short(&workspace_id), + host, + spec, + &overlay_parent, + &cli_bin, + ) + .await + .map_err(|e| e.to_string())?; + + let row = InteractiveSessionRow { + sid: sess.sid.clone(), + workspace_id: workspace_id.clone(), + host_kind: host_kind.clone(), + state: "running".into(), + crash_reason: None, + created_at: chrono::Utc::now().to_rfc3339(), + last_attached_at: None, + last_screen_blob: None, + claude_flags_json: claude_args_json, + pid: None, + }; + let db_path = state.db_path.clone(); + tokio::task::spawn_blocking(move || { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + db.create_interactive_session(&row) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + state + .register_interactive_session(&sess.sid, workspace_id) + .await; + Ok(StartInteractiveResult { + sid: sess.sid, + host_kind, + }) +} + +/// Forward a UTF-8 text payload to the interactive session as a +/// `SendInput { kind: text }` request. The host implementations +/// translate this to either a tmux `send-keys` call (Unix) or an +/// `InputPayload::Text` envelope over the sidecar socket. +#[tauri::command] +pub async fn interactive_send_input( + state: State<'_, AppState>, + sid: String, + text: String, +) -> Result<(), String> { + if !state.claude_interactive_enabled().await { + return Err("Claude Interactive is disabled".into()); + } + let host = state + .host_for_session(&sid) + .await + .ok_or_else(|| format!("interactive session not found: {sid}"))?; + host.send_input(&SessionId(sid), InputPayload::Text { text }) + .await + .map_err(|e| e.to_string()) +} + +/// Capture the current ANSI screen contents for an interactive +/// session. Returns the bytes base64-encoded so the frontend can +/// pipe them through a text-based transport; the same bytes are +/// persisted via [`Database::update_interactive_session_screen`] so +/// a reattach can repaint instantly. +#[tauri::command] +pub async fn interactive_capture_screen( + state: State<'_, AppState>, + sid: String, +) -> Result { + if !state.claude_interactive_enabled().await { + return Err("Claude Interactive is disabled".into()); + } + let host = state + .host_for_session(&sid) + .await + .ok_or_else(|| format!("interactive session not found: {sid}"))?; + let ScreenSnapshot { ansi_bytes, .. } = host + .capture_screen(&SessionId(sid.clone())) + .await + .map_err(|e| e.to_string())?; + + let db_path = state.db_path.clone(); + let blob = ansi_bytes.clone(); + let sid_for_db = sid.clone(); + let _ = tokio::task::spawn_blocking(move || -> Result<(), String> { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + // Persistence is best-effort: a missing row (session was + // already torn down) shouldn't fail the capture command. + // The CRUD layer returns `QueryReturnedNoRows` in that case; + // we recognize it by `Display`-equality so this file doesn't + // need a direct `rusqlite` dep (lib re-exports `Database` but + // not the error type). + match db.update_interactive_session_screen(&sid_for_db, &blob) { + Ok(()) => Ok(()), + Err(e) if e.to_string() == "Query returned no rows" => Ok(()), + Err(e) => Err(e.to_string()), + } + }) + .await + .map_err(|e| e.to_string())?; + + Ok(base64::engine::general_purpose::STANDARD.encode(&ansi_bytes)) +} + +/// Stop an interactive session. `force=true` maps to +/// [`StopMode::Force`] (SIGKILL on tmux, immediate teardown on the +/// sidecar); otherwise [`StopMode::Graceful`]. The DB row is updated +/// to `state = "exited"` and the sid→workspace_id mapping is dropped +/// so the frontend's list view reflects the new state on the next +/// refresh. +#[tauri::command] +pub async fn interactive_stop( + state: State<'_, AppState>, + sid: String, + force: bool, +) -> Result<(), String> { + if !state.claude_interactive_enabled().await { + return Err("Claude Interactive is disabled".into()); + } + let host = state + .host_for_session(&sid) + .await + .ok_or_else(|| format!("interactive session not found: {sid}"))?; + let mode = if force { + StopMode::Force + } else { + StopMode::Graceful + }; + let stop_result = host.stop(&SessionId(sid.clone()), mode).await; + + // Update DB + drop sid mapping regardless of stop_result so a + // partial failure doesn't leave a stale "running" row behind. + let db_path = state.db_path.clone(); + let sid_for_db = sid.clone(); + let _ = tokio::task::spawn_blocking(move || -> Result<(), String> { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + // See `interactive_capture_screen` for the rationale on + // string-matching the "no rows" error instead of pattern- + // matching on `rusqlite::Error`. + match db.set_interactive_session_state(&sid_for_db, "exited", None) { + Ok(()) => Ok(()), + Err(e) if e.to_string() == "Query returned no rows" => Ok(()), + Err(e) => Err(e.to_string()), + } + }) + .await + .map_err(|e| e.to_string())?; + state.unregister_interactive_session(&sid).await; + stop_result.map_err(|e| e.to_string()) +} + +/// List every persisted interactive session for `workspace_id`. The +/// underlying CRUD already orders by `created_at DESC` so the +/// returned list is newest-first. +#[tauri::command] +pub async fn interactive_list_for_workspace( + state: State<'_, AppState>, + workspace_id: String, +) -> Result, String> { + let db_path = state.db_path.clone(); + let rows = + tokio::task::spawn_blocking(move || -> Result, String> { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + db.list_interactive_sessions_for_workspace(&workspace_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(rows.into_iter().map(Into::into).collect()) +} + +/// Wire payload for `interactive:///output`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct OutputPayload { + sid: String, + seq: u64, + bytes_b64: String, +} + +/// Wire payload for `interactive:///exit`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExitPayload { + sid: String, + exit_status: i32, + reason: String, +} + +/// Wire payload for `interactive:///error`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StreamErrorPayload { + sid: String, + message: String, + recoverable: bool, +} + +/// Wire payload for `interactive:///hook`. The inner `hook` +/// shape is the existing `HookFired` enum from the interactive +/// protocol crate, which already derives `Serialize`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HookPayload { + sid: String, + hook: claudette::agent::interactive_protocol::HookFired, +} + +/// Subscribe to the live attach stream for an interactive session +/// and forward events to the frontend as Tauri events. Returns +/// immediately after spawning the forwarding task; the task ends +/// when the host's `AttachStream` terminates (session exit, host +/// disconnect, or detach). +/// +/// Events emitted, all keyed by `sid`: +/// - `interactive:///output` — chunked ANSI bytes (base64). +/// - `interactive:///hook` — Claude Code lifecycle hook fired +/// inside the session. +/// - `interactive:///exit` — host signaled the session exited. +/// - `interactive:///error` — recoverable / fatal stream +/// error from the host. +#[tauri::command] +pub async fn interactive_attach( + app: AppHandle, + state: State<'_, AppState>, + sid: String, +) -> Result<(), String> { + if !state.claude_interactive_enabled().await { + return Err("Claude Interactive is disabled".into()); + } + let host = state + .host_for_session(&sid) + .await + .ok_or_else(|| format!("interactive session not found: {sid}"))?; + spawn_attach_forwarder(app, host, sid).await +} + +/// Helper extracted so the spawned task can be unit-tested as a +/// pure function over an arbitrary `InteractiveHost`. The attach +/// call itself is awaited synchronously so any wire error (e.g. +/// host refused) surfaces to the frontend instead of disappearing +/// into a background task. +async fn spawn_attach_forwarder( + app: AppHandle, + host: Arc, + sid: String, +) -> Result<(), String> { + let session_id = SessionId(sid.clone()); + let (_attach_id, mut stream) = host + .attach(&session_id) + .await + .map_err(|e: HostError| e.to_string())?; + let output_topic = format!("interactive://{sid}/output"); + let hook_topic = format!("interactive://{sid}/hook"); + let exit_topic = format!("interactive://{sid}/exit"); + let error_topic = format!("interactive://{sid}/error"); + tokio::spawn(async move { + while let Some(ev) = stream.next().await { + match ev { + AttachEvent::Output { bytes, seq } => { + let payload = OutputPayload { + sid: sid.clone(), + seq, + bytes_b64: base64::engine::general_purpose::STANDARD.encode(&bytes), + }; + let _ = app.emit(&output_topic, &payload); + } + AttachEvent::Hook(hook) => { + let payload = HookPayload { + sid: sid.clone(), + hook, + }; + let _ = app.emit(&hook_topic, &payload); + } + AttachEvent::Exit { + exit_status, + reason, + } => { + let payload = ExitPayload { + sid: sid.clone(), + exit_status, + reason, + }; + let _ = app.emit(&exit_topic, &payload); + } + AttachEvent::Error { + message, + recoverable, + } => { + let payload = StreamErrorPayload { + sid: sid.clone(), + message, + recoverable, + }; + let _ = app.emit(&error_topic, &payload); + } + } + } + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_short_truncates_to_eight_chars() { + assert_eq!(workspace_short("0123456789abcdef"), "01234567"); + } + + #[test] + fn workspace_short_passes_through_shorter_ids() { + assert_eq!(workspace_short("short"), "short"); + assert_eq!(workspace_short("12345678"), "12345678"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6cf6ecf44..b4e2b39fc 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -22,6 +22,7 @@ pub mod diff; pub mod env; pub mod files; pub mod grammars; +pub mod interactive; pub mod mcp; pub mod metrics; pub mod path_util; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index af7c81fda..9f3e2ebd6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1075,6 +1075,13 @@ fn main() { commands::chat::session::reorder_chat_sessions, commands::chat::session::archive_chat_session, commands::chat::session::restore_chat_session, + // Claude (Interactive) experimental backend + commands::interactive::interactive_start, + commands::interactive::interactive_send_input, + commands::interactive::interactive_capture_screen, + commands::interactive::interactive_stop, + commands::interactive::interactive_list_for_workspace, + commands::interactive::interactive_attach, // Plan commands::plan::read_plan_file, // Agent-managed files (plans, memory, …) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index ec9d95b47..5708d18d8 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use claudette::agent::AgentSession; +use claudette::agent::interactive_host::InteractiveHost; use tokio::sync::{RwLock, Semaphore, mpsc}; use claudette::claude_help::ClaudeFlagDef; @@ -792,6 +793,18 @@ pub struct AppState { /// await held inside it. pub interactive_hook_channels: Arc>>>, + /// Cached `InteractiveHost` keyed by `workspace_id`. The host trait + /// owns connections to tmux / sidecar, so we want to share a single + /// instance per workspace across `interactive_start` / `_send_input` + /// / `_attach` calls. `select_default_host` is awaited lazily on + /// first use through [`AppState::interactive_host_for`]. + pub interactive_hosts: RwLock>>, + /// Reverse index from synthetic interactive `sid` to its owning + /// `workspace_id`. Used by [`AppState::host_for_session`] to route a + /// per-session command back to the workspace-cached host. Populated + /// by [`AppState::register_interactive_session`] and dropped by + /// [`AppState::unregister_interactive_session`]. + pub interactive_sessions: RwLock>, } impl AppState { @@ -849,6 +862,8 @@ impl AppState { workspace_creates_in_flight: Mutex::new(HashSet::new()), scheduler_notify: Arc::new(tokio::sync::Notify::new()), interactive_hook_channels: Arc::new(Mutex::new(HashMap::new())), + interactive_hosts: RwLock::new(HashMap::new()), + interactive_sessions: RwLock::new(HashMap::new()), } } @@ -960,6 +975,158 @@ impl AppState { } } + /// Read the `claudeInteractiveEnabled` experimental flag from the + /// `app_settings` table. Treats any value other than the literal + /// string `"true"` as disabled (matches the F1 stub semantics) and + /// returns `false` if the DB can't be opened — the interactive + /// commands surface the user-facing error themselves. + pub async fn claude_interactive_enabled(&self) -> bool { + let db_path = self.db_path.clone(); + tokio::task::spawn_blocking(move || { + let Ok(db) = claudette::db::Database::open(&db_path) else { + return false; + }; + matches!( + db.get_app_setting("claudeInteractiveEnabled"), + Ok(Some(v)) if v.trim() == "true" + ) + }) + .await + .unwrap_or(false) + } + + /// Per-user runtime directory used for the interactive overlay tree + /// and the sidecar socket. Lives under `claudette_home()` so it + /// honors `$CLAUDETTE_HOME` overrides and the test harness can swap + /// it out by setting `CLAUDETTE_HOME` to a tempdir. + /// + /// `create_dir_all` is best-effort — the host implementations will + /// surface a concrete error if the directory cannot be created when + /// they try to materialize overlays / sockets inside it. + pub async fn runtime_dir_for_interactive(&self) -> std::path::PathBuf { + let dir = claudette::path::claudette_home().join("interactive"); + // Best-effort: ignore errors here so callers see the + // host-layer error if the directory is truly unusable. + let _ = std::fs::create_dir_all(&dir); + dir + } + + /// Path to the bundled `claudette-cli` binary that interactive + /// hooks will shell out to. Resolves relative to `current_exe()` + /// so it works in dev (next to `claudette-app`) and in packaged + /// builds (Contents/MacOS/ on macOS, alongside the .exe on + /// Windows). + /// + /// Task F4 will replace this with a more robust resolution that + /// understands bundled sidecars and Tauri resource paths. For F3 + /// the placeholder is sufficient — the path is only used to embed + /// hook commands inside per-session settings overlays. + pub async fn bundled_cli_binary_path(&self) -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?.to_path_buf(); + #[cfg(windows)] + let candidate = dir.join("claudette-cli.exe"); + #[cfg(not(windows))] + let candidate = dir.join("claudette-cli"); + Some(candidate) + } + + /// Synthesize a wire string for the active interactive host kind. + /// The plan distinguishes `"tmux"` vs `"sidecar"` for the + /// `interactive_sessions.host_kind` column. For F3 we use the + /// same `tmux >= 3.0` probe `select_default_host` uses; if the + /// probe says tmux is available we report `"tmux"`, otherwise + /// `"sidecar"`. The probe result is cached for 30s so this is + /// cheap on the second call within a workspace bringup. + pub async fn interactive_host_kind_for(&self, _workspace_id: &str) -> &'static str { + #[cfg(unix)] + { + use claudette::agent::interactive_host::availability::{TmuxAvailability, check_tmux}; + if matches!(check_tmux().await, TmuxAvailability::Available { .. }) { + return "tmux"; + } + } + "sidecar" + } + + /// Return (or lazily construct) the cached `InteractiveHost` for + /// `workspace_id`. For v1 every workspace shares the same default + /// host (tmux when available on Unix, sidecar otherwise); the map + /// is keyed by workspace so a future per-workspace override can + /// land without touching call sites. + pub async fn interactive_host_for( + &self, + workspace_id: &str, + ) -> Result, claudette::agent::interactive_host::HostError> { + if let Some(host) = self.interactive_hosts.read().await.get(workspace_id) { + return Ok(Arc::clone(host)); + } + let runtime_dir = self.runtime_dir_for_interactive().await; + let sidecar_socket = runtime_dir.join("session-host.sock"); + // For v1 we point the sidecar binary at the bundled CLI's + // sibling `claudette-session-host`. Resolution mirrors the + // CLI: F4 will harden this. + let exe = std::env::current_exe().ok(); + let sidecar_binary = exe + .as_ref() + .and_then(|e| e.parent()) + .map(|p| { + #[cfg(windows)] + { + p.join("claudette-session-host.exe") + } + #[cfg(not(windows))] + { + p.join("claudette-session-host") + } + }) + .unwrap_or_else(|| std::path::PathBuf::from("claudette-session-host")); + let host = claudette::agent::interactive_host::select_default_host( + &runtime_dir, + &sidecar_socket, + &sidecar_binary, + false, + ) + .await?; + let mut hosts = self.interactive_hosts.write().await; + // Re-check under the write lock to avoid clobbering a host + // another concurrent caller just inserted. + if let Some(existing) = hosts.get(workspace_id) { + return Ok(Arc::clone(existing)); + } + hosts.insert(workspace_id.to_string(), Arc::clone(&host)); + Ok(host) + } + + /// Look up the cached host that owns `sid`. Returns `None` if the + /// session is unknown or the host cache entry was evicted (e.g. + /// because the workspace was archived). + pub async fn host_for_session(&self, sid: &str) -> Option> { + let workspace_id = self.interactive_sessions.read().await.get(sid).cloned()?; + self.interactive_hosts + .read() + .await + .get(&workspace_id) + .map(Arc::clone) + } + + /// Register a new interactive session in the sid→workspace_id map + /// so subsequent commands can route by `sid` alone. + pub async fn register_interactive_session(&self, sid: &str, workspace_id: String) { + self.interactive_sessions + .write() + .await + .insert(sid.to_string(), workspace_id); + } + + /// Drop the sid→workspace_id mapping. Safe to call for an unknown + /// `sid` (no-op). The matching host stays cached against the + /// workspace because other sessions in the same workspace may + /// still need it. + pub async fn unregister_interactive_session(&self, sid: &str) { + self.interactive_sessions.write().await.remove(sid); + } + /// Snapshot the plugin registry for use across `await` points. /// /// The lock is held only long enough to `Arc::clone` the inner From 5ff4d05a026f48ffad6e9b36491252d3a1eb88b9 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:14:36 -0700 Subject: [PATCH 38/95] fix(tauri): wire interactive hook channel in start/stop; surface capture DB errors --- src-tauri/src/commands/interactive.rs | 68 ++++++++++++++++++++++++++- src-tauri/src/state.rs | 17 ++++--- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands/interactive.rs b/src-tauri/src/commands/interactive.rs index 525cddb93..5dba04376 100644 --- a/src-tauri/src/commands/interactive.rs +++ b/src-tauri/src/commands/interactive.rs @@ -38,7 +38,7 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use tokio_stream::StreamExt; -use crate::state::AppState; +use crate::state::{AppState, HookEventKind, InteractiveHookEvent}; /// Arguments for [`interactive_start`]. Mirrors /// [`SessionSpec`](claudette::agent::interactive_protocol::SessionSpec) @@ -119,6 +119,7 @@ fn workspace_short(workspace_id: &str) -> &str { /// sid→workspace_id index. #[tauri::command] pub async fn interactive_start( + app: AppHandle, state: State<'_, AppState>, args: StartInteractiveArgs, ) -> Result { @@ -183,12 +184,57 @@ pub async fn interactive_start( state .register_interactive_session(&sess.sid, workspace_id) .await; + + // Wire the per-session hook channel populated by `claudette-cli chat + // hook` → IPC `chat_hook` → `dispatch_interactive_hook`. The CLI-side + // hooks land on this channel; we forward them to the same + // `interactive:///hook` Tauri event topic that the attach stream + // uses so the frontend has a single subscription point regardless of + // event origin (host-observed vs CLI-relayed). + let (hook_tx, mut hook_rx) = tokio::sync::mpsc::unbounded_channel::(); + state.register_interactive_hook_channel(&sess.sid, hook_tx); + let app_clone = app.clone(); + let sid_clone = sess.sid.clone(); + tokio::spawn(async move { + let topic = format!("interactive://{sid_clone}/hook"); + while let Some(ev) = hook_rx.recv().await { + let payload = serde_json::json!({ + "sid": ev.sid, + "kind": kind_to_wire(&ev.kind), + "reason": kind_reason(&ev.kind), + }); + let _ = app_clone.emit(&topic, payload); + } + }); + Ok(StartInteractiveResult { sid: sess.sid, host_kind, }) } +/// Wire-format `kind` string for an [`InteractiveHookEvent`]. Mirrors +/// the parsing in `ipc.rs::parse_hook_event_kind` so a hook round-trips +/// through CLI → channel → frontend without renaming. +fn kind_to_wire(kind: &HookEventKind) -> &str { + match kind { + HookEventKind::Stop => "stop", + HookEventKind::Awaiting { .. } => "awaiting", + HookEventKind::PromptSubmitted => "prompt_submitted", + HookEventKind::SubagentStop => "subagent_stop", + HookEventKind::Unknown { raw_kind } => raw_kind.as_str(), + } +} + +/// Extract the optional `reason` carried alongside a hook kind. Only +/// `Awaiting` has a reason; every other variant returns `None`. +fn kind_reason(kind: &HookEventKind) -> Option<&str> { + match kind { + HookEventKind::Awaiting { reason } => reason.as_deref(), + _ => None, + } +} + /// Forward a UTF-8 text payload to the interactive session as a /// `SendInput { kind: text }` request. The host implementations /// translate this to either a tmux `send-keys` call (Unix) or an @@ -236,7 +282,7 @@ pub async fn interactive_capture_screen( let db_path = state.db_path.clone(); let blob = ansi_bytes.clone(); let sid_for_db = sid.clone(); - let _ = tokio::task::spawn_blocking(move || -> Result<(), String> { + let persist_result = tokio::task::spawn_blocking(move || -> Result<(), String> { let db = Database::open(&db_path).map_err(|e| e.to_string())?; // Persistence is best-effort: a missing row (session was // already torn down) shouldn't fail the capture command. @@ -253,6 +299,20 @@ pub async fn interactive_capture_screen( .await .map_err(|e| e.to_string())?; + // Don't fail the capture command on DB errors — the host already + // produced the snapshot the caller asked for — but surface the + // underlying error so a persist regression doesn't go silent. + match persist_result { + Ok(()) => {} + Err(error) => { + tracing::warn!( + sid = %sid, + ?error, + "capture_screen DB persist failed" + ); + } + } + Ok(base64::engine::general_purpose::STANDARD.encode(&ansi_bytes)) } @@ -300,6 +360,10 @@ pub async fn interactive_stop( .await .map_err(|e| e.to_string())?; state.unregister_interactive_session(&sid).await; + // Drop the sender half of the hook channel so the forwarder task + // spawned in `interactive_start` exits cleanly. Safe to call for an + // unknown sid (no-op) per `AppState::unregister_interactive_hook_channel`. + state.unregister_interactive_hook_channel(&sid); stop_result.map_err(|e| e.to_string()) } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 5708d18d8..4fc72e661 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -901,11 +901,11 @@ impl AppState { /// teardown path so the map doesn't grow without bound across /// session restarts. /// - /// Not yet wired into a production call site: the interactive - /// runner (Tasks F3 / G4) is the eventual caller. Exposed now so - /// the routing pipeline lands and tests as a whole and so the - /// public surface is stable before the consumer arrives. - #[allow(dead_code)] + /// Called from `commands::interactive::interactive_start` (Task F3) + /// to bridge CLI-relayed hooks (`claudette-cli chat hook` → IPC + /// `chat_hook` → [`Self::dispatch_interactive_hook`]) into the + /// `interactive:///hook` Tauri event stream the frontend + /// subscribes to. pub fn register_interactive_hook_channel( &self, sid: &str, @@ -922,10 +922,9 @@ impl AppState { /// for an unknown `sid` (no-op). Doing this on session teardown /// closes the receiver loop in the runner. /// - /// See [`Self::register_interactive_hook_channel`] for the F3 / G4 - /// production caller; tracked here so the public surface lands - /// with E2. - #[allow(dead_code)] + /// Paired with [`Self::register_interactive_hook_channel`]; called + /// from `commands::interactive::interactive_stop` (Task F3) on + /// session teardown. pub fn unregister_interactive_hook_channel(&self, sid: &str) { let mut map = self .interactive_hook_channels From 9578046e9f8f4a7f38b99a8a7e7ae73bb51502c2 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:21:00 -0700 Subject: [PATCH 39/95] build(tauri): bundle claudette-session-host as externalBin sidecar Stage the `claudette-session-host` Rust binary alongside the existing `claudette` CLI and `claudette-pi-harness` sidecars so the interactive backend's sidecar host can locate it via `current_exe().parent()` in both release bundles and the macOS dev `.app` runner. - Add `scripts/stage-session-host-sidecar.sh` modeled after the CLI staging script (cargo `-p claudette-session-host`, repo-relative copy into `src-tauri/binaries/-`). - Chain it from `scripts/stage-cli-sidecar.sh`, forwarding the same `--profile` so dev mirrors don't end up with a release session-host next to a debug GUI. - Register `binaries/claudette-session-host` in both `tauri.conf.json` and `tauri.no-pi.conf.json` so the Pi-less build still ships the session-host. - Fix `AppState::bundled_cli_binary_path` to look for `claudette` (the actual externalBin name) instead of the placeholder `claudette-cli`, add a parallel `bundled_session_host_binary_path` with `CLAUDETTE_SESSION_HOST` env override and repo-relative dev fallback, and rewire `interactive_host_for` to use it. Resolution mirrors `claudette::agent::pi_sdk::resolve_pi_harness_path`. The existing macOS dev runner glob (`*-$host_triple`) already mirrors any staged sidecar into `Contents/MacOS/`, so the new binary is picked up automatically. --- scripts/stage-cli-sidecar.sh | 12 +++ scripts/stage-session-host-sidecar.sh | 130 ++++++++++++++++++++++++ src-tauri/src/state.rs | 136 +++++++++++++++++++------- src-tauri/tauri.conf.json | 2 +- src-tauri/tauri.no-pi.conf.json | 2 +- 5 files changed, 247 insertions(+), 35 deletions(-) create mode 100755 scripts/stage-session-host-sidecar.sh diff --git a/scripts/stage-cli-sidecar.sh b/scripts/stage-cli-sidecar.sh index f8bb62475..aa3356887 100755 --- a/scripts/stage-cli-sidecar.sh +++ b/scripts/stage-cli-sidecar.sh @@ -129,3 +129,15 @@ chmod +x "$dest" echo "▸ Staged $target_bin -> $dest" "$repo_root/scripts/stage-pi-harness-sidecar.sh" "$triple" + +# Chain to the session-host sidecar staging. Forward the same profile so a +# `scripts/stage-cli-sidecar.sh --profile debug` invocation builds the +# session-host in debug mode too — otherwise the dev `.app` would mirror a +# release session-host while the GUI was built debug, doubling cargo compile +# time on first dev launch. +session_host_args=() +if [ "$triple_explicit" = "true" ]; then + session_host_args+=("$triple") +fi +session_host_args+=("--profile" "$profile") +"$repo_root/scripts/stage-session-host-sidecar.sh" "${session_host_args[@]}" diff --git a/scripts/stage-session-host-sidecar.sh b/scripts/stage-session-host-sidecar.sh new file mode 100755 index 000000000..29b3aafb9 --- /dev/null +++ b/scripts/stage-session-host-sidecar.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Compile and stage the `claudette-session-host` binary at the path Tauri's +# bundle.externalBin expects: `src-tauri/binaries/claudette-session-host-` +# (with `.exe` on Windows). Tauri strips the triple suffix at bundle time and +# places the binary alongside the GUI binary in the resulting artifact (.app / +# .deb / AppImage / Windows install dir), where `current_exe().parent()` finds +# it at runtime. +# +# Usage: +# scripts/stage-session-host-sidecar.sh # auto-detect host triple +# scripts/stage-session-host-sidecar.sh # explicit triple (CI) +# scripts/stage-session-host-sidecar.sh --profile debug # dev builds +# scripts/stage-session-host-sidecar.sh --release-built +# # don't rebuild; assume `target//release/claudette-session-host` +# # already exists (CI flow where the binary was built in a separate step). +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +usage() { + sed -n '2,14p' "$0" >&2 +} + +triple="" +profile="release" +release_built=false +triple_explicit=false + +while [ "$#" -gt 0 ]; do + case "$1" in + --release-built) + release_built=true + profile="release" + ;; + --profile) + shift + if [ "${1:-}" = "" ]; then + echo "--profile requires 'debug' or 'release'" >&2 + exit 64 + fi + profile="$1" + ;; + --profile=*) + profile="${1#--profile=}" + ;; + --debug) + profile="debug" + ;; + --release) + profile="release" + ;; + -h|--help) + usage + exit 0 + ;; + --*) + echo "unknown option: $1" >&2 + usage + exit 64 + ;; + *) + if [ "$triple" != "" ]; then + echo "unexpected extra argument: $1" >&2 + usage + exit 64 + fi + triple="$1" + triple_explicit=true + ;; + esac + shift +done + +case "$profile" in + debug|release) ;; + *) + echo "unsupported profile: $profile (expected debug or release)" >&2 + exit 64 + ;; +esac + +if [ "$release_built" = "true" ] && [ "$profile" != "release" ]; then + echo "--release-built can only be used with the release profile" >&2 + exit 64 +fi + +if [ "$triple" = "" ]; then + triple="$(rustc -vV | awk '/host:/ {print $2}')" +fi + +case "$triple" in + *windows*) bin_name="claudette-session-host.exe" ;; + *) bin_name="claudette-session-host" ;; +esac + +if [ "$profile" = "debug" ] && [ "$triple_explicit" != "true" ]; then + target_bin="target/debug/${bin_name}" +else + target_bin="target/${triple}/${profile}/${bin_name}" +fi + +if [ "$release_built" != "true" ]; then + echo "▸ Building claudette-session-host (${profile}) for ${triple}" + cargo_args=(build -p claudette-session-host) + if [ "$profile" = "release" ] || [ "$triple_explicit" = "true" ]; then + cargo_args+=(--target "${triple}") + fi + if [ "$profile" = "release" ]; then + cargo_args=(build --release --target "${triple}" -p claudette-session-host) + fi + cargo "${cargo_args[@]}" +fi + +if [ ! -f "$target_bin" ]; then + echo "expected built binary not found: $target_bin" >&2 + exit 66 +fi + +dest_dir="src-tauri/binaries" +mkdir -p "$dest_dir" + +case "$triple" in + *windows*) dest="${dest_dir}/claudette-session-host-${triple}.exe" ;; + *) dest="${dest_dir}/claudette-session-host-${triple}" ;; +esac + +cp "$target_bin" "$dest" +chmod +x "$dest" +echo "▸ Staged $target_bin -> $dest" diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 4fc72e661..4c73dc6f0 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1010,24 +1010,81 @@ impl AppState { dir } - /// Path to the bundled `claudette-cli` binary that interactive - /// hooks will shell out to. Resolves relative to `current_exe()` - /// so it works in dev (next to `claudette-app`) and in packaged - /// builds (Contents/MacOS/ on macOS, alongside the .exe on - /// Windows). + /// Path to the bundled `claudette` CLI binary that interactive + /// hooks will shell out to. The CLI is staged by + /// `scripts/stage-cli-sidecar.sh` and registered in + /// `tauri.conf.json`'s `bundle.externalBin` as `binaries/claudette`; + /// at runtime it lives next to the GUI binary (`Contents/MacOS/` + /// on macOS, alongside the `.exe` on Windows, alongside the + /// AppImage on Linux). In dev the macOS app runner mirrors the + /// staged sidecar into the same layout so `current_exe().parent()` + /// resolves identically. /// - /// Task F4 will replace this with a more robust resolution that - /// understands bundled sidecars and Tauri resource paths. For F3 - /// the placeholder is sufficient — the path is only used to embed - /// hook commands inside per-session settings overlays. + /// Returns `None` if `current_exe` cannot be read or if the + /// resolved candidate does not exist on disk — the latter is the + /// common dev-without-staging case, and callers surface a + /// "claudette-cli binary not found" error so the user knows to + /// re-run `scripts/stage-cli-sidecar.sh`. pub async fn bundled_cli_binary_path(&self) -> Option { let exe = std::env::current_exe().ok()?; - let dir = exe.parent()?.to_path_buf(); - #[cfg(windows)] - let candidate = dir.join("claudette-cli.exe"); - #[cfg(not(windows))] - let candidate = dir.join("claudette-cli"); - Some(candidate) + let dir = exe.parent()?; + let suffix = std::env::consts::EXE_SUFFIX; + let candidate = dir.join(format!("claudette{suffix}")); + if candidate.exists() { + return Some(candidate); + } + // Dev fallback: repo-relative staged path. Mirrors + // `claudette::agent::pi_sdk::resolve_pi_harness_path` so a + // bare `cargo run` (which doesn't go through the macOS dev + // runner) can still find the sidecar. + let triple = host_triple(); + let staged = std::path::PathBuf::from("src-tauri") + .join("binaries") + .join(format!("claudette-{triple}{suffix}")); + if staged.exists() { + return Some(staged); + } + None + } + + /// Path to the bundled `claudette-session-host` sidecar used by + /// the interactive backend's sidecar host. The session-host is + /// staged by `scripts/stage-session-host-sidecar.sh` and + /// registered in `tauri.conf.json`'s `bundle.externalBin` as + /// `binaries/claudette-session-host`; at runtime it lives next to + /// the GUI binary (`Contents/MacOS/` on macOS, alongside the + /// `.exe` on Windows, alongside the AppImage on Linux). In dev + /// the macOS app runner mirrors the staged sidecar into the same + /// layout. + /// + /// Returns `None` if `current_exe` cannot be read or no candidate + /// exists on disk. The sidecar host falls back to a bare + /// `claudette-session-host` (PATH lookup) when this is `None`, + /// which lets a `cargo run`-from-workspace flow still work when + /// the user has the binary on PATH. + pub async fn bundled_session_host_binary_path(&self) -> Option { + if let Ok(override_path) = std::env::var("CLAUDETTE_SESSION_HOST") { + return Some(std::path::PathBuf::from(override_path)); + } + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let suffix = std::env::consts::EXE_SUFFIX; + let candidate = dir.join(format!("claudette-session-host{suffix}")); + if candidate.exists() { + return Some(candidate); + } + // Dev fallback: repo-relative staged path. Mirrors + // `claudette::agent::pi_sdk::resolve_pi_harness_path` so a + // bare `cargo run` (which doesn't go through the macOS dev + // runner) can still find the sidecar. + let triple = host_triple(); + let staged = std::path::PathBuf::from("src-tauri") + .join("binaries") + .join(format!("claudette-session-host-{triple}{suffix}")); + if staged.exists() { + return Some(staged); + } + None } /// Synthesize a wire string for the active interactive host kind. @@ -1062,24 +1119,19 @@ impl AppState { } let runtime_dir = self.runtime_dir_for_interactive().await; let sidecar_socket = runtime_dir.join("session-host.sock"); - // For v1 we point the sidecar binary at the bundled CLI's - // sibling `claudette-session-host`. Resolution mirrors the - // CLI: F4 will harden this. - let exe = std::env::current_exe().ok(); - let sidecar_binary = exe - .as_ref() - .and_then(|e| e.parent()) - .map(|p| { - #[cfg(windows)] - { - p.join("claudette-session-host.exe") - } - #[cfg(not(windows))] - { - p.join("claudette-session-host") - } - }) - .unwrap_or_else(|| std::path::PathBuf::from("claudette-session-host")); + // Resolve the staged `claudette-session-host` sidecar through + // the same Tauri externalBin path the CLI uses. Falls back to + // a bare binary name so a PATH-installed + // `claudette-session-host` still works when the bundle is + // missing (developer running `cargo run` straight out of the + // workspace without going through `scripts/dev.sh`). + let sidecar_binary = self + .bundled_session_host_binary_path() + .await + .unwrap_or_else(|| { + let suffix = std::env::consts::EXE_SUFFIX; + std::path::PathBuf::from(format!("claudette-session-host{suffix}")) + }); let host = claudette::agent::interactive_host::select_default_host( &runtime_dir, &sidecar_socket, @@ -1146,6 +1198,24 @@ impl AppState { } } +/// Host target triple matching the staged sidecar naming used by +/// `scripts/stage-*-sidecar.sh`. Mirrors the lookup table in +/// `claudette::agent::pi_sdk::host_triple` so the repo-relative dev +/// fallback used by [`AppState::bundled_cli_binary_path`] and +/// [`AppState::bundled_session_host_binary_path`] picks the same +/// directory entry the staging scripts wrote. +fn host_triple() -> &'static str { + match (std::env::consts::ARCH, std::env::consts::OS) { + ("aarch64", "macos") => "aarch64-apple-darwin", + ("x86_64", "macos") => "x86_64-apple-darwin", + ("x86_64", "linux") => "x86_64-unknown-linux-gnu", + ("aarch64", "linux") => "aarch64-unknown-linux-gnu", + ("x86_64", "windows") => "x86_64-pc-windows-msvc", + ("aarch64", "windows") => "aarch64-pc-windows-msvc", + _ => "unknown", + } +} + #[cfg(test)] #[cfg(unix)] mod tests { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c3b37fcf0..6f9f2dbbf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -54,7 +54,7 @@ "active": true, "targets": "all", "createUpdaterArtifacts": true, - "externalBin": ["binaries/claudette", "binaries/claudette-pi-harness"], + "externalBin": ["binaries/claudette", "binaries/claudette-pi-harness", "binaries/claudette-session-host"], "resources": ["binaries/pi/package.json"], "icon": [ "icons/32x32.png", diff --git a/src-tauri/tauri.no-pi.conf.json b/src-tauri/tauri.no-pi.conf.json index 0b67a2664..64763587a 100644 --- a/src-tauri/tauri.no-pi.conf.json +++ b/src-tauri/tauri.no-pi.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "bundle": { - "externalBin": ["binaries/claudette"], + "externalBin": ["binaries/claudette", "binaries/claudette-session-host"], "resources": [] } } From 57af970bfab1b67e7ad8ea52ba36714b2acb705c Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:24:03 -0700 Subject: [PATCH 40/95] fix(tauri): forward --release-built to session-host stage; add CLAUDETTE_CLI env override --- scripts/stage-cli-sidecar.sh | 3 +++ src-tauri/src/state.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/scripts/stage-cli-sidecar.sh b/scripts/stage-cli-sidecar.sh index aa3356887..d0ad5faf5 100755 --- a/scripts/stage-cli-sidecar.sh +++ b/scripts/stage-cli-sidecar.sh @@ -140,4 +140,7 @@ if [ "$triple_explicit" = "true" ]; then session_host_args+=("$triple") fi session_host_args+=("--profile" "$profile") +if [ "$release_built" = "true" ]; then + session_host_args+=("--release-built") +fi "$repo_root/scripts/stage-session-host-sidecar.sh" "${session_host_args[@]}" diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 4c73dc6f0..f8e570cce 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1026,6 +1026,9 @@ impl AppState { /// "claudette-cli binary not found" error so the user knows to /// re-run `scripts/stage-cli-sidecar.sh`. pub async fn bundled_cli_binary_path(&self) -> Option { + if let Ok(p) = std::env::var("CLAUDETTE_CLI") { + return Some(PathBuf::from(p)); + } let exe = std::env::current_exe().ok()?; let dir = exe.parent()?; let suffix = std::env::consts::EXE_SUFFIX; From c80095b49c1b08bcf8ba446f271acdc6cc82dbd5 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:28:10 -0700 Subject: [PATCH 41/95] feat(ui): add Claude (Interactive) toggle to Experimental settings Adds an experimental-flag row to Settings > Experimental that toggles the claudeInteractiveEnabled flag from the settings slice (A1). Mirrors the existing pluginManagementEnabled / claudeRemoteControlEnabled rows: a switch + label + description that flips the store value and persists via setAppSetting("claude_interactive_enabled", ...). Translations added in en / pt-BR / es / zh-CN / ja. Co-Authored-By: Claude Opus 4.7 --- .../sections/ExperimentalSettings.test.tsx | 33 ++++++++++++++ .../sections/ExperimentalSettings.tsx | 44 +++++++++++++++++++ src/ui/src/locales/en/settings.json | 3 ++ src/ui/src/locales/es/settings.json | 3 ++ src/ui/src/locales/ja/settings.json | 3 ++ src/ui/src/locales/pt-BR/settings.json | 3 ++ src/ui/src/locales/zh-CN/settings.json | 3 ++ 7 files changed, 92 insertions(+) diff --git a/src/ui/src/components/settings/sections/ExperimentalSettings.test.tsx b/src/ui/src/components/settings/sections/ExperimentalSettings.test.tsx index 1da005f50..7f99fbf2a 100644 --- a/src/ui/src/components/settings/sections/ExperimentalSettings.test.tsx +++ b/src/ui/src/components/settings/sections/ExperimentalSettings.test.tsx @@ -11,6 +11,10 @@ const appStore = vi.hoisted(() => ({ }), settingsFocus: null as string | null, clearSettingsFocus: vi.fn(), + claudeInteractiveEnabled: false, + setClaudeInteractiveEnabled: vi.fn((next: boolean) => { + appStore.claudeInteractiveEnabled = next; + }), })); const serviceMocks = vi.hoisted(() => ({ @@ -159,3 +163,32 @@ describe("ExperimentalSettings — Usage Insights consent gate", () => { ); }); }); + +describe("ExperimentalSettings — Claude (Interactive) toggle", () => { + beforeEach(() => { + appStore.claudeInteractiveEnabled = false; + appStore.setClaudeInteractiveEnabled.mockClear(); + }); + + it("toggles claudeInteractiveEnabled when the switch is clicked", async () => { + const container = await renderSettings(); + const toggle = container.querySelector( + 'button[aria-label="experimental_claude_interactive_aria"]', + ) as HTMLButtonElement | null; + if (!toggle) throw new Error("Claude (Interactive) toggle not found"); + + expect(toggle.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + toggle.click(); + await Promise.resolve(); + }); + + expect(appStore.setClaudeInteractiveEnabled).toHaveBeenCalledWith(true); + expect(appStore.claudeInteractiveEnabled).toBe(true); + expect(serviceMocks.setAppSetting).toHaveBeenCalledWith( + "claude_interactive_enabled", + "true", + ); + }); +}); diff --git a/src/ui/src/components/settings/sections/ExperimentalSettings.tsx b/src/ui/src/components/settings/sections/ExperimentalSettings.tsx index 9f32a1aae..f3313386d 100644 --- a/src/ui/src/components/settings/sections/ExperimentalSettings.tsx +++ b/src/ui/src/components/settings/sections/ExperimentalSettings.tsx @@ -17,6 +17,12 @@ export function ExperimentalSettings() { const clearSettingsFocus = useAppStore((s) => s.clearSettingsFocus); const usageInsightsEnabled = useAppStore((s) => s.usageInsightsEnabled); const setUsageInsightsEnabled = useAppStore((s) => s.setUsageInsightsEnabled); + const claudeInteractiveEnabled = useAppStore( + (s) => s.claudeInteractiveEnabled, + ); + const setClaudeInteractiveEnabled = useAppStore( + (s) => s.setClaudeInteractiveEnabled, + ); const [error, setError] = useState(null); const [usageConfirmOpen, setUsageConfirmOpen] = useState(false); const usageRowRef = useRef(null); @@ -64,12 +70,50 @@ export function ExperimentalSettings() { await applyUsageInsights(false); }; + const handleClaudeInteractiveToggle = async () => { + const next = !claudeInteractiveEnabled; + setClaudeInteractiveEnabled(next); + try { + setError(null); + await setAppSetting( + "claude_interactive_enabled", + next ? "true" : "false", + ); + } catch (e) { + setClaudeInteractiveEnabled(!next); + setError(String(e)); + } + }; + return (

{t("experimental_title")}

{error &&
{error}
} +
+
+
+ {t("experimental_claude_interactive")} +
+
+ {t("experimental_claude_interactive_desc")} +
+
+
+ +
+
+
Date: Sat, 16 May 2026 22:36:55 -0700 Subject: [PATCH 42/95] feat(ui): expose Claude (Interactive) runtime card gated by experimental flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Claude (Interactive)" runtime card in Models settings that mirrors the Rust-side `AgentBackendRuntimeHarness::ClaudeInteractive` variant from F1. The card renders as `aria-disabled` with an "Enable in Experimental" hint when the `claudeInteractiveEnabled` flag is off, and becomes active when the flag flips on. Also extends the TS-side `AgentBackendRuntimeHarness` union with the new `claude_interactive` value and wires it through the `RuntimeSelector` switch statements so exhaustiveness holds. The canonical harness matrix (`agent_backend_matrix.json`) is intentionally left untouched — `ClaudeInteractive` is absent from `available_harnesses()` by design; the experimental gate is enforced server-side in `AgentBackendConfig::effective_harness_kind`. Co-Authored-By: Claude Opus 4.7 --- .../components/settings/RuntimeSelector.tsx | 4 ++ .../settings/sections/ModelSettings.test.tsx | 37 ++++++++++++++++++- .../settings/sections/ModelSettings.tsx | 24 ++++++++++++ src/ui/src/locales/en/settings.json | 4 ++ src/ui/src/locales/es/settings.json | 4 ++ src/ui/src/locales/ja/settings.json | 4 ++ src/ui/src/locales/pt-BR/settings.json | 4 ++ src/ui/src/locales/zh-CN/settings.json | 4 ++ src/ui/src/services/tauri/agentBackends.ts | 6 +++ 9 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/ui/src/components/settings/RuntimeSelector.tsx b/src/ui/src/components/settings/RuntimeSelector.tsx index 46b3c7cf4..1c0353519 100644 --- a/src/ui/src/components/settings/RuntimeSelector.tsx +++ b/src/ui/src/components/settings/RuntimeSelector.tsx @@ -21,6 +21,8 @@ function harnessLabelKey(harness: AgentBackendRuntimeHarness): string { switch (harness) { case "claude_code": return "models_backend_runtime_claude_cli_label"; + case "claude_interactive": + return "models_backend_runtime_claude_interactive_label"; case "pi_sdk": return "models_backend_runtime_pi_label"; case "codex_app_server": @@ -32,6 +34,8 @@ function harnessFallbackLabel(harness: AgentBackendRuntimeHarness): string { switch (harness) { case "claude_code": return "Claude CLI"; + case "claude_interactive": + return "Claude (Interactive)"; case "pi_sdk": return "Pi"; case "codex_app_server": diff --git a/src/ui/src/components/settings/sections/ModelSettings.test.tsx b/src/ui/src/components/settings/sections/ModelSettings.test.tsx index b1668468d..e9465a6d0 100644 --- a/src/ui/src/components/settings/sections/ModelSettings.test.tsx +++ b/src/ui/src/components/settings/sections/ModelSettings.test.tsx @@ -41,6 +41,11 @@ const appStore = vi.hoisted(() => ({ setClaudeAuthMethod: vi.fn((method: string | null) => { appStore.claudeAuthMethod = method; }), + claudeInteractiveEnabled: false, + setClaudeInteractiveEnabled: vi.fn((enabled: boolean) => { + appStore.claudeInteractiveEnabled = enabled; + }), + piSdkAvailable: true, })); const serviceMocks = vi.hoisted(() => ({ @@ -195,7 +200,7 @@ vi.mock("@tauri-apps/api/event", () => ({ vi.mock("react-i18next", () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, fallback?: string) => fallback ?? key, }), })); @@ -246,6 +251,7 @@ describe("ModelSettings", () => { appStore.agentBackends = []; appStore.settingsFocus = null; appStore.claudeAuthFailure = null; + appStore.claudeInteractiveEnabled = false; for (const value of Object.values(appStore)) { if (typeof value === "function" && "mockClear" in value) { value.mockClear(); @@ -470,6 +476,35 @@ describe("ModelSettings", () => { expect(appStore.setClaudeAuthFailure).not.toHaveBeenCalled(); }); + it("runtime-card-claude-interactive renders disabled when claudeInteractiveEnabled is false", async () => { + appStore.claudeInteractiveEnabled = false; + const container = await renderModelSettings(); + await act(async () => { + await Promise.resolve(); + }); + + const card = container.querySelector( + '[data-testid="runtime-card-claude-interactive"]', + ); + expect(card).not.toBeNull(); + expect(card?.getAttribute("aria-disabled")).toBe("true"); + expect(card?.textContent ?? "").toMatch(/enable in experimental/i); + }); + + it("runtime-card-claude-interactive renders selectable when flag is on", async () => { + appStore.claudeInteractiveEnabled = true; + const container = await renderModelSettings(); + await act(async () => { + await Promise.resolve(); + }); + + const card = container.querySelector( + '[data-testid="runtime-card-claude-interactive"]', + ); + expect(card).not.toBeNull(); + expect(card?.getAttribute("aria-disabled")).toBe("false"); + }); + it("does not resolve a chat auth failure until sign-in validates", async () => { appStore.claudeAuthFailure = { messageId: "assistant-1", diff --git a/src/ui/src/components/settings/sections/ModelSettings.tsx b/src/ui/src/components/settings/sections/ModelSettings.tsx index 278f3a348..631d3ad58 100644 --- a/src/ui/src/components/settings/sections/ModelSettings.tsx +++ b/src/ui/src/components/settings/sections/ModelSettings.tsx @@ -75,6 +75,7 @@ export function ModelSettings() { const setAlternativeBackendsEnabled = useAppStore((s) => s.setAlternativeBackendsEnabled); const codexEnabled = useAppStore((s) => s.codexEnabled); const setCodexEnabled = useAppStore((s) => s.setCodexEnabled); + const claudeInteractiveEnabled = useAppStore((s) => s.claudeInteractiveEnabled); const agentBackends = useAppStore((s) => s.agentBackends); const setAgentBackends = useAppStore((s) => s.setAgentBackends); const setDefaultAgentBackendId = useAppStore((s) => s.setDefaultAgentBackendId); @@ -638,6 +639,29 @@ export function ModelSettings() {
+
+
+
+ {t("models_runtime_claude_interactive_label", "Claude (Interactive)")} +
+
+ {claudeInteractiveEnabled + ? t( + "models_runtime_claude_interactive_desc", + "Run interactive claude inside a detachable host (tmux on Unix, sidecar on Windows). Survives Claudette closing.", + ) + : t( + "models_runtime_claude_interactive_disabled_hint", + "Enable in Experimental settings to use this runtime.", + )} +
+
+
+ {(anthropicBackend || visibleBackends.length > 0) && ( Date: Sat, 16 May 2026 22:42:18 -0700 Subject: [PATCH 43/95] fix(ui): honor claude_interactive harness override in effectiveHarness when flag is on --- .../settings/RuntimeSelector.test.tsx | 4 + .../components/settings/RuntimeSelector.tsx | 3 +- .../settings/sections/ModelSettings.tsx | 5 + .../src/services/tauri/agentBackends.test.ts | 109 ++++++++++++++++++ src/ui/src/services/tauri/agentBackends.ts | 25 +++- 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 src/ui/src/services/tauri/agentBackends.test.ts diff --git a/src/ui/src/components/settings/RuntimeSelector.test.tsx b/src/ui/src/components/settings/RuntimeSelector.test.tsx index 0d08df518..ba8d94b68 100644 --- a/src/ui/src/components/settings/RuntimeSelector.test.tsx +++ b/src/ui/src/components/settings/RuntimeSelector.test.tsx @@ -11,6 +11,10 @@ const appStore = vi.hoisted(() => ({ // to the same shape a real Pi-compiled binary would produce. A // dedicated test below pins the no-Pi behaviour. piSdkAvailable: true, + // Experimental gate consumed by RuntimeSelector via effectiveHarness. + // Defaults to off because the selector's existing matrix tests don't + // care about the interactive harness. + claudeInteractiveEnabled: false, })); vi.mock("../../stores/useAppStore", () => { diff --git a/src/ui/src/components/settings/RuntimeSelector.tsx b/src/ui/src/components/settings/RuntimeSelector.tsx index 1c0353519..3c51c2cf5 100644 --- a/src/ui/src/components/settings/RuntimeSelector.tsx +++ b/src/ui/src/components/settings/RuntimeSelector.tsx @@ -64,7 +64,8 @@ export function RuntimeSelector({ backend, onSaved, onError }: RuntimeSelectorPr return piSdkAvailable ? all : all.filter((h) => h !== "pi_sdk"); }, [backend.kind, piSdkAvailable]); const defaultHarness = defaultHarnessForKind(backend.kind); - const current = effectiveHarness(backend); + const claudeInteractiveEnabled = useAppStore((s) => s.claudeInteractiveEnabled); + const current = effectiveHarness(backend, { claudeInteractiveEnabled }); const piEnabled = useAppStore((s) => s.agentBackends.some((b) => b.kind === "pi_sdk" && b.enabled), ); diff --git a/src/ui/src/components/settings/sections/ModelSettings.tsx b/src/ui/src/components/settings/sections/ModelSettings.tsx index 631d3ad58..c5f6d219d 100644 --- a/src/ui/src/components/settings/sections/ModelSettings.tsx +++ b/src/ui/src/components/settings/sections/ModelSettings.tsx @@ -639,6 +639,11 @@ export function ModelSettings() { + {/* TODO(G2 follow-up): card is currently display-only. Actual runtime + selection happens in RuntimeSelector. When that flow is wired to + accept claude_interactive (via Fix 1 to effectiveHarness), revisit + whether this card should also offer a direct "Use this runtime" + affordance. */}
({ invoke: vi.fn() })); + +import { + type AgentBackendConfig, + effectiveHarness, +} from "./agentBackends"; + +function backend(overrides: Partial = {}): AgentBackendConfig { + return { + id: "test", + label: "Test", + kind: "ollama", + base_url: null, + enabled: true, + default_model: null, + manual_models: [], + discovered_models: [], + auth_ref: null, + capabilities: { + thinking: false, + effort: false, + fast_mode: false, + one_m_context: false, + tools: true, + vision: false, + }, + context_window_default: 64_000, + model_discovery: true, + has_secret: false, + ...overrides, + }; +} + +describe("effectiveHarness", () => { + it("returns the kind's default harness when the override is 'claude_interactive' and the flag is OFF", () => { + // Ollama's default is `pi_sdk`; `claude_interactive` is never in + // `availableHarnessesForKind`, so without the flag the override + // must be discarded and we should land on the kind default rather + // than silently dispatch into the gated harness. + const config = backend({ + kind: "ollama", + runtime_harness: "claude_interactive", + }); + expect( + effectiveHarness(config, { claudeInteractiveEnabled: false }), + ).toBe("pi_sdk"); + // Same expectation when the caller doesn't pass the option at all. + expect(effectiveHarness(config)).toBe("pi_sdk"); + }); + + it("honors a 'claude_interactive' override when the flag is ON", () => { + const config = backend({ + kind: "ollama", + runtime_harness: "claude_interactive", + }); + expect( + effectiveHarness(config, { claudeInteractiveEnabled: true }), + ).toBe("claude_interactive"); + }); + + it("honors a 'claude_code' override for a kind that allows it, regardless of the flag (no regression)", () => { + // openai_api supports both `claude_code` and `pi_sdk`; pinning + // `claude_code` must still work whether or not the experimental + // flag is on. + const config = backend({ + kind: "openai_api", + runtime_harness: "claude_code", + }); + expect( + effectiveHarness(config, { claudeInteractiveEnabled: true }), + ).toBe("claude_code"); + expect( + effectiveHarness(config, { claudeInteractiveEnabled: false }), + ).toBe("claude_code"); + }); + + it("falls back to the kind's default when the override is missing", () => { + const config = backend({ kind: "anthropic", runtime_harness: null }); + expect(effectiveHarness(config)).toBe("claude_code"); + }); + + it("falls back to the kind's default when the override isn't in the kind's available set", () => { + // anthropic's available harnesses are `["claude_code"]`; a stale / + // hand-edited `pi_sdk` override should be ignored. + const config = backend({ + kind: "anthropic", + runtime_harness: "pi_sdk", + }); + expect( + effectiveHarness(config, { claudeInteractiveEnabled: true }), + ).toBe("claude_code"); + }); +}); diff --git a/src/ui/src/services/tauri/agentBackends.ts b/src/ui/src/services/tauri/agentBackends.ts index 390265e18..ba07e82e9 100644 --- a/src/ui/src/services/tauri/agentBackends.ts +++ b/src/ui/src/services/tauri/agentBackends.ts @@ -192,11 +192,34 @@ export function availableHarnessesForKind( } /** Effective harness for a config: persisted override when allowed, - * otherwise the kind's default. Matches `AgentBackendConfig::effective_harness`. */ + * otherwise the kind's default. + * + * Mirrors the Rust-side `AgentBackendConfig::effective_harness_kind` + * (in `src/agent_backend.rs`): the persisted override is honored when + * it appears in `availableHarnessesForKind(kind)`, OR when it's + * `"claude_interactive"` AND the `claudeInteractiveEnabled` + * experimental flag is on. `"claude_interactive"` is intentionally + * absent from `availableHarnessesForKind` because the gate is the + * experimental flag, not the per-kind matrix — so any caller that + * has the flag value must pass it here, otherwise a backend with + * `runtime_harness === "claude_interactive"` silently falls back to + * the kind's default harness (a frontend/backend state mismatch). + * + * @param backend Persisted backend config. + * @param options.claudeInteractiveEnabled Value of the experimental + * flag from the Zustand store. Defaults to `false` for callers that + * don't (yet) know about the flag — same as the Rust default. */ export function effectiveHarness( backend: AgentBackendConfig, + options?: { claudeInteractiveEnabled?: boolean }, ): AgentBackendRuntimeHarness { const override = backend.runtime_harness ?? undefined; + if ( + override === "claude_interactive" && + options?.claudeInteractiveEnabled === true + ) { + return override; + } if (override && availableHarnessesForKind(backend.kind).includes(override)) { return override; } From 108d9589203bbebdf23721fc390d07d7a35fcf5b Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:47:03 -0700 Subject: [PATCH 44/95] feat(ui): add interactive Tauri bridge service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the F3 `interactive_*` Tauri commands and `interactive:///*` events behind a typed module so G4-G8 components depend on a single canonical TS surface instead of calling `invoke` / `listen` directly. The `interactive:///hook` topic carries two divergent payload shapes — the attach-stream emits `{sid, hook: HookFired}` (nested, tag-on-`kind`) while the CLI-relayed forwarder emits flat `{sid, kind, reason}`. `subscribeHooks` accepts either via `normalizeHookPayload`, which collapses both to a stable `HookEvent` and surfaces `HookFired::Unknown.raw_kind` as `reason` so schema drift stays labeled in logs. Also exposes `subscribeExit` / `subscribeStreamError` for the other two attach-stream topics F3 emits but the plan stub didn't enumerate. --- src/ui/src/services/interactive.test.ts | 406 ++++++++++++++++++++++++ src/ui/src/services/interactive.ts | 325 +++++++++++++++++++ 2 files changed, 731 insertions(+) create mode 100644 src/ui/src/services/interactive.test.ts create mode 100644 src/ui/src/services/interactive.ts diff --git a/src/ui/src/services/interactive.test.ts b/src/ui/src/services/interactive.test.ts new file mode 100644 index 000000000..466ad94f3 --- /dev/null +++ b/src/ui/src/services/interactive.test.ts @@ -0,0 +1,406 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- +// Capture-style mocks for both invoke (commands) and listen (events) so +// individual tests can assert the literal Tauri command name + arg shape +// and replay a fake event payload through the registered listener. + +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args), +})); + +type ListenHandler = (event: { payload: unknown }) => void; +const listeners = new Map(); +vi.mock("@tauri-apps/api/event", () => ({ + listen: (eventName: string, handler: ListenHandler) => { + const list = listeners.get(eventName) ?? []; + list.push(handler); + listeners.set(eventName, list); + return Promise.resolve(() => { + const after = + listeners.get(eventName)?.filter((h) => h !== handler) ?? []; + listeners.set(eventName, after); + }); + }, +})); + +import { + attach, + captureScreen, + type HookEvent, + listInteractive, + normalizeHookPayload, + type OutputEvent, + sendInput, + startInteractive, + type StartInteractiveArgs, + stopInteractive, + subscribeExit, + subscribeHooks, + subscribeOutput, + subscribeStreamError, +} from "./interactive"; + +// --------------------------------------------------------------------------- +// Command surface +// --------------------------------------------------------------------------- + +describe("interactive service — commands", () => { + beforeEach(() => { + invokeMock.mockReset(); + listeners.clear(); + }); + + it("startInteractive invokes interactive_start with the camelCase args object", async () => { + invokeMock.mockResolvedValueOnce({ sid: "S-1", hostKind: "tmux" }); + + const args: StartInteractiveArgs = { + workspaceId: "ws-1", + workingDir: "/tmp/ws-1", + rows: 24, + cols: 80, + claudeBinary: "/usr/local/bin/claude", + claudeArgs: ["--print", "--output-format", "stream-json"], + }; + const out = await startInteractive(args); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith("interactive_start", { args }); + expect(out).toEqual({ sid: "S-1", hostKind: "tmux" }); + }); + + it("sendInput invokes interactive_send_input with sid + text", async () => { + invokeMock.mockResolvedValueOnce(undefined); + + await sendInput("S-1", "hello world"); + + expect(invokeMock).toHaveBeenCalledWith("interactive_send_input", { + sid: "S-1", + text: "hello world", + }); + }); + + it("captureScreen invokes interactive_capture_screen and returns the base64 string", async () => { + invokeMock.mockResolvedValueOnce("YWJjZA=="); + + const ansiB64 = await captureScreen("S-1"); + + expect(invokeMock).toHaveBeenCalledWith("interactive_capture_screen", { + sid: "S-1", + }); + expect(ansiB64).toBe("YWJjZA=="); + }); + + it("stopInteractive defaults force=false", async () => { + invokeMock.mockResolvedValueOnce(undefined); + + await stopInteractive("S-1"); + + expect(invokeMock).toHaveBeenCalledWith("interactive_stop", { + sid: "S-1", + force: false, + }); + }); + + it("stopInteractive passes force=true when requested", async () => { + invokeMock.mockResolvedValueOnce(undefined); + + await stopInteractive("S-1", true); + + expect(invokeMock).toHaveBeenCalledWith("interactive_stop", { + sid: "S-1", + force: true, + }); + }); + + it("attach invokes interactive_attach with sid", async () => { + invokeMock.mockResolvedValueOnce(undefined); + + await attach("S-1"); + + expect(invokeMock).toHaveBeenCalledWith("interactive_attach", { + sid: "S-1", + }); + }); + + it("listInteractive invokes interactive_list_for_workspace with workspaceId", async () => { + invokeMock.mockResolvedValueOnce([]); + + const rows = await listInteractive("ws-1"); + + expect(invokeMock).toHaveBeenCalledWith("interactive_list_for_workspace", { + workspaceId: "ws-1", + }); + expect(rows).toEqual([]); + }); + + it("listInteractive returns rows with the persisted camelCase shape", async () => { + invokeMock.mockResolvedValueOnce([ + { + sid: "S-1", + workspaceId: "ws-1", + hostKind: "tmux", + state: "running", + crashReason: null, + createdAt: "2026-05-16T22:00:00Z", + lastAttachedAt: null, + lastScreenBlob: null, + claudeFlagsJson: "[]", + pid: null, + }, + ]); + + const rows = await listInteractive("ws-1"); + expect(rows).toHaveLength(1); + expect(rows[0].sid).toBe("S-1"); + expect(rows[0].state).toBe("running"); + expect(rows[0].lastScreenBlob).toBeNull(); + }); + + it("propagates errors from invoke so callers can surface them", async () => { + invokeMock.mockRejectedValueOnce(new Error("Claude Interactive is disabled")); + + await expect(sendInput("S-1", "x")).rejects.toThrow( + "Claude Interactive is disabled", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Output subscription +// --------------------------------------------------------------------------- + +describe("interactive service — subscribeOutput", () => { + beforeEach(() => { + invokeMock.mockReset(); + listeners.clear(); + }); + + it("registers a listener on the per-sid output topic and unwraps the payload", async () => { + const received: OutputEvent[] = []; + await subscribeOutput("S-1", (ev) => received.push(ev)); + + const handlers = listeners.get("interactive://S-1/output"); + expect(handlers).toHaveLength(1); + + handlers?.[0]({ + payload: { sid: "S-1", bytesB64: "QQ==", seq: 1 }, + }); + + expect(received).toEqual([{ sid: "S-1", bytesB64: "QQ==", seq: 1 }]); + }); + + it("returns an unlisten function that detaches the handler", async () => { + const unlisten = await subscribeOutput("S-1", () => {}); + expect(listeners.get("interactive://S-1/output")).toHaveLength(1); + + unlisten(); + + expect(listeners.get("interactive://S-1/output") ?? []).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Hook payload normalization (the F3 divergence) +// --------------------------------------------------------------------------- + +describe("interactive service — normalizeHookPayload", () => { + it("collapses the flat CLI-relayed shape verbatim for known kinds", () => { + expect( + normalizeHookPayload({ sid: "S-1", kind: "stop" }), + ).toEqual({ sid: "S-1", kind: "stop" }); + + expect( + normalizeHookPayload({ + sid: "S-1", + kind: "awaiting", + reason: "permission", + }), + ).toEqual({ + sid: "S-1", + kind: "awaiting", + reason: "permission", + }); + }); + + it("collapses the nested attach-stream shape (HookPayload { sid, hook: HookFired })", () => { + expect( + normalizeHookPayload({ + sid: "S-1", + hook: { kind: "stop" }, + }), + ).toEqual({ sid: "S-1", kind: "stop" }); + + expect( + normalizeHookPayload({ + sid: "S-1", + hook: { kind: "awaiting", reason: "permission" }, + }), + ).toEqual({ + sid: "S-1", + kind: "awaiting", + reason: "permission", + }); + + expect( + normalizeHookPayload({ + sid: "S-1", + hook: { kind: "prompt_submitted" }, + }), + ).toEqual({ sid: "S-1", kind: "prompt_submitted" }); + + expect( + normalizeHookPayload({ + sid: "S-1", + hook: { kind: "subagent_stop" }, + }), + ).toEqual({ sid: "S-1", kind: "subagent_stop" }); + }); + + it("treats the nested null reason as absent", () => { + expect( + normalizeHookPayload({ + sid: "S-1", + hook: { kind: "awaiting", reason: null }, + }), + ).toEqual({ sid: "S-1", kind: "awaiting" }); + }); + + it("maps unknown kinds to 'unknown' and surfaces the original label as reason", () => { + // CLI-relayed: `kind_to_wire` passes the raw_kind string through for + // HookEventKind::Unknown. + expect( + normalizeHookPayload({ sid: "S-1", kind: "FutureHook" }), + ).toEqual({ sid: "S-1", kind: "unknown" }); + + // Attach-stream: HookFired::Unknown serializes with kind="unknown" and + // carries raw_kind. We surface that as reason so logs can label the drift. + expect( + normalizeHookPayload({ + sid: "S-1", + hook: { kind: "unknown", raw_kind: "FutureHook", raw_payload: "{}" }, + }), + ).toEqual({ + sid: "S-1", + kind: "unknown", + reason: "FutureHook", + }); + }); + + it("normalizes both shapes to an identical HookEvent for the same logical hook", () => { + const flat = normalizeHookPayload({ + sid: "S-1", + kind: "awaiting", + reason: "blocked on permission", + }); + const nested = normalizeHookPayload({ + sid: "S-1", + hook: { kind: "awaiting", reason: "blocked on permission" }, + }); + expect(flat).toEqual(nested); + }); +}); + +// --------------------------------------------------------------------------- +// Hook subscription +// --------------------------------------------------------------------------- + +describe("interactive service — subscribeHooks", () => { + beforeEach(() => { + invokeMock.mockReset(); + listeners.clear(); + }); + + it("normalizes the flat CLI-relayed payload through the registered listener", async () => { + const received: HookEvent[] = []; + await subscribeHooks("S-1", (ev) => received.push(ev)); + + const handlers = listeners.get("interactive://S-1/hook"); + expect(handlers).toHaveLength(1); + + handlers?.[0]({ + payload: { sid: "S-1", kind: "awaiting", reason: "permission" }, + }); + + expect(received).toEqual([ + { sid: "S-1", kind: "awaiting", reason: "permission" }, + ]); + }); + + it("normalizes the nested attach-stream payload through the registered listener", async () => { + const received: HookEvent[] = []; + await subscribeHooks("S-1", (ev) => received.push(ev)); + + const handlers = listeners.get("interactive://S-1/hook"); + handlers?.[0]({ + payload: { + sid: "S-1", + hook: { kind: "awaiting", reason: "permission" }, + }, + }); + + expect(received).toEqual([ + { sid: "S-1", kind: "awaiting", reason: "permission" }, + ]); + }); + + it("delivers identical HookEvents regardless of which F3 payload shape arrives", async () => { + const received: HookEvent[] = []; + await subscribeHooks("S-1", (ev) => received.push(ev)); + + const handlers = listeners.get("interactive://S-1/hook"); + handlers?.[0]({ + payload: { sid: "S-1", kind: "stop" }, + }); + handlers?.[0]({ + payload: { sid: "S-1", hook: { kind: "stop" } }, + }); + + expect(received).toHaveLength(2); + expect(received[0]).toEqual(received[1]); + }); +}); + +// --------------------------------------------------------------------------- +// Exit + error subscriptions +// --------------------------------------------------------------------------- + +describe("interactive service — subscribeExit / subscribeStreamError", () => { + beforeEach(() => { + invokeMock.mockReset(); + listeners.clear(); + }); + + it("subscribeExit listens on the per-sid exit topic", async () => { + const seen: unknown[] = []; + await subscribeExit("S-1", (ev) => seen.push(ev)); + + const handlers = listeners.get("interactive://S-1/exit"); + expect(handlers).toHaveLength(1); + + handlers?.[0]({ + payload: { sid: "S-1", exitStatus: 0, reason: "exited" }, + }); + + expect(seen).toEqual([{ sid: "S-1", exitStatus: 0, reason: "exited" }]); + }); + + it("subscribeStreamError listens on the per-sid error topic", async () => { + const seen: unknown[] = []; + await subscribeStreamError("S-1", (ev) => seen.push(ev)); + + const handlers = listeners.get("interactive://S-1/error"); + expect(handlers).toHaveLength(1); + + handlers?.[0]({ + payload: { sid: "S-1", message: "boom", recoverable: false }, + }); + + expect(seen).toEqual([ + { sid: "S-1", message: "boom", recoverable: false }, + ]); + }); +}); diff --git a/src/ui/src/services/interactive.ts b/src/ui/src/services/interactive.ts new file mode 100644 index 000000000..72cd1a3c1 --- /dev/null +++ b/src/ui/src/services/interactive.ts @@ -0,0 +1,325 @@ +// Typed Tauri bridge for the Claude (Interactive) experimental backend. +// +// Mirrors the Rust command surface in `src-tauri/src/commands/interactive.rs` +// (F3) and is intentionally kept as a sibling of `tauri.ts` rather than +// piled into that already-large file. Components landing in G4-G8 (turn +// assembler, terminal panel, sidebar list, etc.) should depend on this +// module instead of calling `invoke` / `listen` directly so the wire +// shapes have one canonical TypeScript representation. + +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +// --------------------------------------------------------------------------- +// Command arg / result shapes +// --------------------------------------------------------------------------- + +/** + * Arguments for `interactive_start`. Mirrors Rust + * `commands::interactive::StartInteractiveArgs`; field names are + * camelCase because Tauri's `invoke` macro rewrites the Rust + * `snake_case` field names from the `#[derive(Deserialize)]` struct to + * camelCase on the JS side. + */ +export interface StartInteractiveArgs { + workspaceId: string; + workingDir: string; + rows: number; + cols: number; + claudeBinary: string; + claudeArgs: string[]; +} + +/** + * Return value of `interactive_start`. `hostKind` is `"tmux"` on Unix + * when the user has tmux available and `"sidecar"` otherwise (and + * always `"sidecar"` on Windows). The frontend uses this string + * verbatim alongside the persisted `interactive_sessions.host_kind` + * column. + */ +export interface StartInteractiveResult { + sid: string; + hostKind: string; +} + +/** + * Wire shape for a persisted `interactive_sessions` row, returned by + * `interactive_list_for_workspace`. Mirrors Rust + * `commands::interactive::InteractiveSessionListItem` (camelCase via + * `#[serde(rename_all = "camelCase")]`). + * + * `lastScreenBlob` is the persisted last-captured screen bytes as a + * byte array (Tauri serializes `Vec` to a JSON number array). It's + * `null` for sessions that never had `captureScreen` called. + */ +export interface InteractiveSessionRow { + sid: string; + workspaceId: string; + hostKind: string; + state: string; + crashReason: string | null; + createdAt: string; + lastAttachedAt: string | null; + lastScreenBlob: number[] | null; + claudeFlagsJson: string; + pid: number | null; +} + +// --------------------------------------------------------------------------- +// Event payload shapes +// --------------------------------------------------------------------------- + +/** Payload of `interactive:///output`. ANSI bytes are base64. */ +export interface OutputEvent { + sid: string; + bytesB64: string; + seq: number; +} + +/** + * Stable set of hook kinds the frontend cares about. `"unknown"` + * carries the raw name in `reason` so we can log schema drift without + * the typed turn assembler having to handle every future variant. + */ +export type HookKind = + | "stop" + | "awaiting" + | "prompt_submitted" + | "subagent_stop" + | "unknown"; + +/** + * Normalized hook event the frontend consumes. Both the attach-stream + * (nested `HookPayload { sid, hook: HookFired }`) and the + * CLI-relayed path (flat `{ sid, kind, reason }`) collapse into this + * shape via `normalizeHookPayload`. + */ +export interface HookEvent { + sid: string; + kind: HookKind; + reason?: string; +} + +/** Payload of `interactive:///exit`. */ +export interface ExitEvent { + sid: string; + exitStatus: number; + reason: string; +} + +/** Payload of `interactive:///error`. */ +export interface StreamErrorEvent { + sid: string; + message: string; + recoverable: boolean; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** + * Spawn a fresh interactive Claude session. Persists an + * `interactive_sessions` row in state `"running"`. Caller must then + * invoke {@link attach} (and subscribe via {@link subscribeOutput} / + * {@link subscribeHooks}) to receive live events. + */ +export function startInteractive( + args: StartInteractiveArgs, +): Promise { + return invoke("interactive_start", { args }); +} + +/** + * Send a UTF-8 text payload to a running interactive session. The + * underlying host translates this to a tmux `send-keys` call (Unix + * tmux host) or an `InputPayload::Text` envelope over the sidecar + * socket. + */ +export function sendInput(sid: string, text: string): Promise { + return invoke("interactive_send_input", { sid, text }); +} + +/** + * Capture the current ANSI screen contents for a session. The returned + * string is base64-encoded raw ANSI bytes. Best-effort persisted via + * `Database::update_interactive_session_screen` so a reattach can + * repaint instantly. + */ +export function captureScreen(sid: string): Promise { + return invoke("interactive_capture_screen", { sid }); +} + +/** + * Stop a running interactive session. `force=true` maps to a + * `StopMode::Force` (SIGKILL on tmux, immediate teardown on sidecar); + * the default is `StopMode::Graceful`. The DB row is updated to + * `state = "exited"` and the sid→workspace_id mapping is dropped. + */ +export function stopInteractive(sid: string, force = false): Promise { + return invoke("interactive_stop", { sid, force }); +} + +/** + * List every persisted interactive session for `workspaceId`. The + * underlying CRUD orders by `created_at DESC`, so the returned list is + * newest-first. + */ +export function listInteractive( + workspaceId: string, +): Promise { + return invoke("interactive_list_for_workspace", { + workspaceId, + }); +} + +/** + * Subscribe to the live attach stream for an interactive session. + * Spawns a Rust-side forwarder that fans `AttachEvent::Output / Hook / + * Exit / Error` to the matching `interactive:///...` Tauri + * events. Returns immediately after the attach handshake; the + * forwarder runs until the host's `AttachStream` terminates. + */ +export function attach(sid: string): Promise { + return invoke("interactive_attach", { sid }); +} + +// --------------------------------------------------------------------------- +// Event subscriptions +// --------------------------------------------------------------------------- + +/** + * Subscribe to `interactive:///output`. The returned promise + * resolves to an unlisten function — invoke it to drop the listener. + */ +export function subscribeOutput( + sid: string, + fn: (ev: OutputEvent) => void, +): Promise { + return listen(`interactive://${sid}/output`, (e) => + fn(e.payload), + ); +} + +/** + * Raw payload shapes accepted on the `interactive:///hook` topic. + * + * F3 emits two different shapes on this topic depending on where the + * hook came from: + * + * 1. Attach-stream path (`spawn_attach_forwarder` in + * `commands/interactive.rs`): the typed `HookPayload { sid, hook: + * HookFired }`, where `HookFired` is `#[serde(tag = "kind", + * rename_all = "snake_case")]` so the inner shape is `{ kind: + * "stop"|"awaiting"|"prompt_submitted"|"subagent_stop"|"unknown", + * reason?, raw_kind?, raw_payload? }`. + * 2. CLI-relayed path (`interactive_start`'s hook channel forwarder): + * a flat `{ sid, kind, reason? }` object. + * + * G3 accepts either and normalizes to {@link HookEvent} so downstream + * code (G4 turn assembler) doesn't have to discriminate. + */ +type NestedHookPayload = { + sid: string; + hook: { + kind: string; + reason?: string | null; + raw_kind?: string; + raw_payload?: string; + }; +}; + +type FlatHookPayload = { + sid: string; + kind: string; + reason?: string | null; +}; + +type RawHookPayload = NestedHookPayload | FlatHookPayload; + +function isNestedHookPayload(p: RawHookPayload): p is NestedHookPayload { + return ( + typeof (p as NestedHookPayload).hook === "object" && + (p as NestedHookPayload).hook !== null + ); +} + +const KNOWN_HOOK_KINDS: ReadonlySet = new Set([ + "stop", + "awaiting", + "prompt_submitted", + "subagent_stop", + "unknown", +]); + +function coerceHookKind(raw: string): HookKind { + return KNOWN_HOOK_KINDS.has(raw as HookKind) ? (raw as HookKind) : "unknown"; +} + +/** + * Collapse either of F3's hook payload shapes into the canonical + * {@link HookEvent}. Exported for tests / advanced callers; subscribe + * via {@link subscribeHooks} for the normal case. + * + * For the nested variant, an `Unknown` HookFired carries `raw_kind` — + * we surface that as `reason` so logs / UI still have a label for the + * unrecognized hook name. For the flat variant we trust the dispatched + * `kind` string (the Rust side already normalized via + * `kind_to_wire`). + */ +export function normalizeHookPayload(raw: RawHookPayload): HookEvent { + if (isNestedHookPayload(raw)) { + const inner = raw.hook; + const kind = coerceHookKind(inner.kind); + if (kind === "unknown") { + // Prefer the typed `raw_kind` over the absent `reason` so the + // schema-drift label survives the round-trip. + const label = inner.raw_kind ?? inner.reason ?? undefined; + return label !== undefined && label !== null + ? { sid: raw.sid, kind, reason: label } + : { sid: raw.sid, kind }; + } + const reason = inner.reason ?? undefined; + return reason !== undefined && reason !== null + ? { sid: raw.sid, kind, reason } + : { sid: raw.sid, kind }; + } + const kind = coerceHookKind(raw.kind); + const reason = raw.reason ?? undefined; + return reason !== undefined && reason !== null + ? { sid: raw.sid, kind, reason } + : { sid: raw.sid, kind }; +} + +/** + * Subscribe to `interactive:///hook`. Accepts both F3 payload + * shapes (attach-stream nested + CLI-relayed flat) and normalizes to + * {@link HookEvent} before invoking `fn`. The returned promise + * resolves to an unlisten function. + */ +export function subscribeHooks( + sid: string, + fn: (ev: HookEvent) => void, +): Promise { + return listen(`interactive://${sid}/hook`, (e) => { + fn(normalizeHookPayload(e.payload)); + }); +} + +/** Subscribe to `interactive:///exit`. */ +export function subscribeExit( + sid: string, + fn: (ev: ExitEvent) => void, +): Promise { + return listen(`interactive://${sid}/exit`, (e) => fn(e.payload)); +} + +/** Subscribe to `interactive:///error`. */ +export function subscribeStreamError( + sid: string, + fn: (ev: StreamErrorEvent) => void, +): Promise { + return listen(`interactive://${sid}/error`, (e) => + fn(e.payload), + ); +} From 2cd37e49cddbdba98a30ec3cbf38d19045096671 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:49:58 -0700 Subject: [PATCH 45/95] fix(ui): preserve unknown-kind label on flat hook path; export RawHookPayload types --- src/ui/src/services/interactive.test.ts | 16 +++++++++++++++- src/ui/src/services/interactive.ts | 8 ++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/ui/src/services/interactive.test.ts b/src/ui/src/services/interactive.test.ts index 466ad94f3..91233f067 100644 --- a/src/ui/src/services/interactive.test.ts +++ b/src/ui/src/services/interactive.test.ts @@ -274,7 +274,11 @@ describe("interactive service — normalizeHookPayload", () => { // HookEventKind::Unknown. expect( normalizeHookPayload({ sid: "S-1", kind: "FutureHook" }), - ).toEqual({ sid: "S-1", kind: "unknown" }); + ).toEqual({ + sid: "S-1", + kind: "unknown", + reason: "FutureHook", + }); // Attach-stream: HookFired::Unknown serializes with kind="unknown" and // carries raw_kind. We surface that as reason so logs can label the drift. @@ -290,6 +294,16 @@ describe("interactive service — normalizeHookPayload", () => { }); }); + it("preserves the original kind label as reason for flat-path unknown kinds", () => { + expect( + normalizeHookPayload({ sid: "S-2", kind: "SomeFutureHookName" }), + ).toEqual({ + sid: "S-2", + kind: "unknown", + reason: "SomeFutureHookName", + }); + }); + it("normalizes both shapes to an identical HookEvent for the same logical hook", () => { const flat = normalizeHookPayload({ sid: "S-1", diff --git a/src/ui/src/services/interactive.ts b/src/ui/src/services/interactive.ts index 72cd1a3c1..d4dde4d31 100644 --- a/src/ui/src/services/interactive.ts +++ b/src/ui/src/services/interactive.ts @@ -219,7 +219,7 @@ export function subscribeOutput( * G3 accepts either and normalizes to {@link HookEvent} so downstream * code (G4 turn assembler) doesn't have to discriminate. */ -type NestedHookPayload = { +export type NestedHookPayload = { sid: string; hook: { kind: string; @@ -229,13 +229,13 @@ type NestedHookPayload = { }; }; -type FlatHookPayload = { +export type FlatHookPayload = { sid: string; kind: string; reason?: string | null; }; -type RawHookPayload = NestedHookPayload | FlatHookPayload; +export type RawHookPayload = NestedHookPayload | FlatHookPayload; function isNestedHookPayload(p: RawHookPayload): p is NestedHookPayload { return ( @@ -285,7 +285,7 @@ export function normalizeHookPayload(raw: RawHookPayload): HookEvent { : { sid: raw.sid, kind }; } const kind = coerceHookKind(raw.kind); - const reason = raw.reason ?? undefined; + const reason = raw.reason ?? (kind === "unknown" ? raw.kind : undefined); return reason !== undefined && reason !== null ? { sid: raw.sid, kind, reason } : { sid: raw.sid, kind }; From f61ceced3d31137470f93f6375836480126e9b5a Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:54:42 -0700 Subject: [PATCH 46/95] feat(ui): hook-delimited turn assembler for interactive sessions Add `useInteractiveTurnAssembler(sid)` plus a pure `assemblerReducer` that consumes G3's `subscribeOutput` / `subscribeHooks` / `subscribeExit` streams and folds them into a flat list of turns delimited by `UserPromptSubmit` and `Stop` hooks, alongside `awaitingInput` / `crashed` aux state. Pre-prompt output is preserved as turn 0; `awaiting` is idempotent; `unknown` hooks log via the `reason` raw_kind fallback; `exit` marks the live turn `crashed`. Co-Authored-By: Claude Opus 4.7 --- .../hooks/useInteractiveTurnAssembler.test.ts | 303 ++++++++++++++++ .../src/hooks/useInteractiveTurnAssembler.ts | 325 ++++++++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 src/ui/src/hooks/useInteractiveTurnAssembler.test.ts create mode 100644 src/ui/src/hooks/useInteractiveTurnAssembler.ts diff --git a/src/ui/src/hooks/useInteractiveTurnAssembler.test.ts b/src/ui/src/hooks/useInteractiveTurnAssembler.test.ts new file mode 100644 index 000000000..c31f3648e --- /dev/null +++ b/src/ui/src/hooks/useInteractiveTurnAssembler.test.ts @@ -0,0 +1,303 @@ +// @vitest-environment happy-dom + +// Unit tests for G4's pure reducer and the hook's subscription wiring. +// The bulk of the coverage targets `assemblerReducer` directly — it's +// a pure function so it can be driven without React/Tauri. We add a +// single integration-style test that mocks the three G3 subscribe +// helpers to confirm the hook actually plumbs events through to the +// reducer. + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { + assemblerReducer, + initialAssemblerState, + type AssemblerEvent, + type AssemblerState, +} from "./useInteractiveTurnAssembler"; + +// --------------------------------------------------------------------------- +// Small fixture helpers — keep the test bodies focused on assertions. +// --------------------------------------------------------------------------- + +function bytes(str: string): Uint8Array { + // TextEncoder is part of the lib types in tsconfig.app.json and + // exists in both the node and happy-dom test environments vitest + // uses, so this works without extra polyfills. + return new TextEncoder().encode(str); +} + +function output(str: string, seq = 0): AssemblerEvent { + return { type: "output", bytes: bytes(str), seq }; +} + +function decode(buf: Uint8Array): string { + return new TextDecoder().decode(buf); +} + +function reduce( + initial: AssemblerState, + events: AssemblerEvent[], +): AssemblerState { + return events.reduce(assemblerReducer, initial); +} + +// --------------------------------------------------------------------------- +// Reducer tests +// --------------------------------------------------------------------------- + +describe("useInteractiveTurnAssembler reducer", () => { + it("emits a turn on Stop", () => { + const state = reduce(initialAssemblerState, [ + { type: "hook", kind: "prompt_submitted" }, + output("hello "), + output("world"), + { type: "hook", kind: "stop" }, + ]); + + expect(state.turns).toHaveLength(1); + expect(state.turns[0].id).toBe(0); + expect(state.turns[0].status).toBe("done"); + expect(decode(state.turns[0].bytes)).toBe("hello world"); + expect(state.awaitingInput).toBe(false); + expect(state.crashed).toBe(false); + }); + + it("preserves pre-prompt output as a transient turn 0", () => { + // The very first output before any `prompt_submitted` should not + // be dropped — it carries Claude's splash / banner. + const state = reduce(initialAssemblerState, [ + output("welcome banner"), + { type: "hook", kind: "prompt_submitted" }, + output("answer"), + { type: "hook", kind: "stop" }, + ]); + + expect(state.turns).toHaveLength(2); + expect(state.turns[0].id).toBe(0); + expect(decode(state.turns[0].bytes)).toBe("welcome banner"); + expect(state.turns[0].status).toBe("done"); // closed by next prompt + expect(state.turns[1].id).toBe(1); + expect(decode(state.turns[1].bytes)).toBe("answer"); + expect(state.turns[1].status).toBe("done"); + }); + + it("clears awaiting badge when UserPromptSubmit fires", () => { + const afterAwaiting = reduce(initialAssemblerState, [ + { type: "hook", kind: "prompt_submitted" }, + output("waiting on you..."), + { type: "hook", kind: "awaiting" }, + ]); + expect(afterAwaiting.awaitingInput).toBe(true); + + const afterSubmit = assemblerReducer(afterAwaiting, { + type: "hook", + kind: "prompt_submitted", + }); + expect(afterSubmit.awaitingInput).toBe(false); + // Previous turn was closed, a new live one opened. + expect(afterSubmit.turns).toHaveLength(2); + expect(afterSubmit.turns[0].status).toBe("done"); + expect(afterSubmit.turns[1].status).toBe("live"); + }); + + it("ignores duplicate awaiting events", () => { + const first = assemblerReducer(initialAssemblerState, { + type: "hook", + kind: "awaiting", + }); + expect(first.awaitingInput).toBe(true); + + // Same reference means React can bail on the re-render. + const second = assemblerReducer(first, { + type: "hook", + kind: "awaiting", + }); + expect(second).toBe(first); + }); + + it("flips to crashed state on Exit event", () => { + const state = reduce(initialAssemblerState, [ + { type: "hook", kind: "prompt_submitted" }, + output("partial answer"), + { type: "exit", reason: "signal: SIGKILL" }, + ]); + + expect(state.crashed).toBe(true); + expect(state.turns).toHaveLength(1); + expect(state.turns[0].status).toBe("crashed"); + expect(decode(state.turns[0].bytes)).toBe("partial answer"); + }); + + it("falls back to raw_kind for unknown hooks (logs and no-ops)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const before = reduce(initialAssemblerState, [ + { type: "hook", kind: "prompt_submitted" }, + output("partial"), + ]); + // Unknown kind must not perturb the turns list or aux state. + const after = assemblerReducer(before, { + type: "hook", + kind: "unknown", + reason: "SomeFutureHook", + }); + + expect(after.turns).toBe(before.turns); + expect(after.awaitingInput).toBe(before.awaitingInput); + expect(after.crashed).toBe(before.crashed); + expect(warn).toHaveBeenCalledWith( + "[interactive] ignoring unknown hook kind:", + "SomeFutureHook", + ); + } finally { + warn.mockRestore(); + } + }); + + it("subagent_stop is a no-op in v1", () => { + const before = reduce(initialAssemblerState, [ + { type: "hook", kind: "prompt_submitted" }, + output("orchestrator output"), + ]); + const after = assemblerReducer(before, { + type: "hook", + kind: "subagent_stop", + }); + // Same reference — no visible state change. + expect(after).toBe(before); + }); + + it("stop with no live turn is a no-op", () => { + const after = assemblerReducer(initialAssemblerState, { + type: "hook", + kind: "stop", + }); + expect(after).toBe(initialAssemblerState); + }); +}); + +// --------------------------------------------------------------------------- +// Hook integration test — confirms wiring through to the reducer. +// --------------------------------------------------------------------------- + +type OutputHandler = (ev: { sid: string; bytesB64: string; seq: number }) => void; +type HookHandler = (ev: { + sid: string; + kind: + | "stop" + | "awaiting" + | "prompt_submitted" + | "subagent_stop" + | "unknown"; + reason?: string; +}) => void; +type ExitHandler = (ev: { sid: string; exitStatus: number; reason: string }) => void; + +const harness = { + outputHandlers: [] as OutputHandler[], + hookHandlers: [] as HookHandler[], + exitHandlers: [] as ExitHandler[], + unlistens: [] as Array>, +}; + +vi.mock("../services/interactive", () => ({ + subscribeOutput: vi.fn((_sid: string, fn: OutputHandler) => { + harness.outputHandlers.push(fn); + const unlisten = vi.fn(); + harness.unlistens.push(unlisten); + return Promise.resolve(unlisten); + }), + subscribeHooks: vi.fn((_sid: string, fn: HookHandler) => { + harness.hookHandlers.push(fn); + const unlisten = vi.fn(); + harness.unlistens.push(unlisten); + return Promise.resolve(unlisten); + }), + subscribeExit: vi.fn((_sid: string, fn: ExitHandler) => { + harness.exitHandlers.push(fn); + const unlisten = vi.fn(); + harness.unlistens.push(unlisten); + return Promise.resolve(unlisten); + }), +})); + +beforeEach(() => { + harness.outputHandlers = []; + harness.hookHandlers = []; + harness.exitHandlers = []; + harness.unlistens = []; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useInteractiveTurnAssembler hook wiring", () => { + it("subscribes to all three event streams when a sid is provided", async () => { + // Use renderHook from @testing-library/react if available; otherwise + // fall back to a small manual mount via react-dom/client. The + // codebase's existing tests use the manual mount pattern, so we + // mirror that here to avoid pulling in another dep. + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + const React = await import("react"); + const { useInteractiveTurnAssembler } = await import( + "./useInteractiveTurnAssembler" + ); + + let capturedState: ReturnType | null = + null; + + function Probe({ sid }: { sid: string | null }) { + capturedState = useInteractiveTurnAssembler(sid); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + try { + await act(async () => { + root.render(React.createElement(Probe, { sid: "sid-1" })); + }); + + // Subscriptions registered via the mocked module. + expect(harness.outputHandlers).toHaveLength(1); + expect(harness.hookHandlers).toHaveLength(1); + expect(harness.exitHandlers).toHaveLength(1); + + // Drive a small scripted exchange through the handlers and + // confirm the reducer state visible to React reflects it. + await act(async () => { + harness.hookHandlers[0]({ + sid: "sid-1", + kind: "prompt_submitted", + }); + // bytesB64 for "hi" in base64 is "aGk=". + harness.outputHandlers[0]({ + sid: "sid-1", + bytesB64: "aGk=", + seq: 1, + }); + harness.hookHandlers[0]({ sid: "sid-1", kind: "stop" }); + }); + + expect(capturedState).not.toBeNull(); + const state = capturedState as unknown as AssemblerState; + expect(state.turns).toHaveLength(1); + expect(state.turns[0].status).toBe("done"); + expect(decode(state.turns[0].bytes)).toBe("hi"); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + } + + // Cleanup invoked all three unlisten functions. + for (const u of harness.unlistens) { + expect(u).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/ui/src/hooks/useInteractiveTurnAssembler.ts b/src/ui/src/hooks/useInteractiveTurnAssembler.ts new file mode 100644 index 000000000..9f483fec0 --- /dev/null +++ b/src/ui/src/hooks/useInteractiveTurnAssembler.ts @@ -0,0 +1,325 @@ +// Hook-delimited turn assembler for interactive (tmux/sidecar) Claude +// sessions. +// +// Consumes the three event streams exposed by G3's +// `services/interactive.ts` — output bytes, normalized hooks, and exit — +// and folds them into a flat list of "turns". A turn is the continuous +// run of output bytes between a `UserPromptSubmit` hook (the user +// pressed Enter) and the next `Stop` hook (the agent finished its +// reply). The reducer also tracks two pieces of auxiliary state the UI +// surfaces near the chat input: `awaitingInput` (Claude asked a +// question via `AskUserQuestion` / `ExitPlanMode`) and `crashed` (the +// child process exited, expected or otherwise). +// +// G4 keeps the reducer pure (`assemblerReducer`) so it can be unit +// tested without React, while the hook is just `useReducer` plus the +// G3 subscriptions wired in via `useEffect`. + +import { useEffect, useReducer } from "react"; + +import { + subscribeExit, + subscribeHooks, + subscribeOutput, + type HookKind, +} from "../services/interactive"; +import { base64ToBytes } from "../utils/base64"; + +// --------------------------------------------------------------------------- +// Public state shape +// --------------------------------------------------------------------------- + +/** + * One assembled chunk of agent output. The id is monotonic per + * session — turn 0 is reserved for any output observed *before* the + * first `UserPromptSubmit` (e.g. the splash / banner Claude prints at + * launch). Subsequent turns are numbered 1, 2, … in submission order. + * + * `status` reflects lifecycle: + * - `live` — accepting more output bytes + * - `done` — closed by a `stop` or implicit close on the next + * `prompt_submitted` + * - `crashed`— the underlying session emitted an `exit` while this + * turn was still live + */ +export interface Turn { + id: number; + bytes: Uint8Array; + status: "live" | "done" | "crashed"; +} + +/** Aggregated assembler state surfaced to React. */ +export interface AssemblerState { + turns: Turn[]; + awaitingInput: boolean; + crashed: boolean; +} + +// --------------------------------------------------------------------------- +// Reducer events +// --------------------------------------------------------------------------- + +/** + * Internal event union dispatched into the reducer. We keep this + * narrower than the raw `OutputEvent` / `HookEvent` / `ExitEvent` so + * the reducer never depends on `sid` or other transport plumbing — + * subscription wiring lives entirely in the hook. + */ +export type AssemblerEvent = + | { type: "output"; bytes: Uint8Array; seq: number } + | { type: "hook"; kind: HookKind; reason?: string } + | { type: "exit"; reason: string }; + +export const initialAssemblerState: AssemblerState = { + turns: [], + awaitingInput: false, + crashed: false, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Concatenate two byte buffers. Allocates a fresh `Uint8Array` so the + * reducer stays referentially-pure with respect to its inputs — never + * mutate `prev` in place, otherwise React's `useReducer` may bail on a + * re-render because the surrounding turn array still points at the + * same object. + */ +function concatBytes(prev: Uint8Array, next: Uint8Array): Uint8Array { + const out = new Uint8Array(prev.length + next.length); + out.set(prev, 0); + out.set(next, prev.length); + return out; +} + +/** + * Find the active (live) turn's index, or `-1` if none. We look at the + * tail of the array first because the reducer only ever appends, so + * the live turn — when one exists — is always last. + */ +function liveTurnIndex(turns: readonly Turn[]): number { + if (turns.length === 0) return -1; + return turns[turns.length - 1].status === "live" ? turns.length - 1 : -1; +} + +function replaceTurn(turns: readonly Turn[], index: number, replacement: Turn): Turn[] { + const out = turns.slice(); + out[index] = replacement; + return out; +} + +/** Next monotonic turn id. The "before-first-prompt" turn is 0. */ +function nextTurnId(turns: readonly Turn[]): number { + if (turns.length === 0) return 0; + return turns[turns.length - 1].id + 1; +} + +// --------------------------------------------------------------------------- +// Pure reducer +// --------------------------------------------------------------------------- + +/** + * Pure event reducer. Exported separately from the hook so tests can + * drive it without mounting a React component or mocking Tauri. + * + * Semantics: + * + * - `output` — append bytes to the live turn. If none + * exists yet (no `prompt_submitted` has fired) + * start a transient turn 0 so the splash / + * banner is preserved. + * - `prompt_submitted` — close the live turn as `done` (acts like an + * implicit Stop for the previous turn), open + * a fresh live turn, and clear + * `awaitingInput` (the user just responded). + * - `stop` — close the live turn as `done`. No-op when + * nothing is live. + * - `awaiting` — set `awaitingInput = true`. Idempotent — + * duplicate awaiting events do not produce a + * new state object, so React skips a re-render. + * - `subagent_stop` — v1: ignored. The orchestrating agent's own + * `stop` is what closes the visible turn. + * - `unknown` — log a one-shot warning with the surfaced + * raw label and otherwise leave state alone. + * The hook's normalization layer guarantees + * the `reason` field carries the raw kind. + * - `exit` — mark the live turn (if any) as `crashed` + * and set the global `crashed` flag. + * + * The reducer never mutates its inputs and always returns the same + * reference when an event is a no-op so consumers can use referential + * equality to short-circuit work. + */ +export function assemblerReducer( + state: AssemblerState, + event: AssemblerEvent, +): AssemblerState { + switch (event.type) { + case "output": { + const liveIdx = liveTurnIndex(state.turns); + if (liveIdx === -1) { + // No live turn yet — open the "before-first-prompt" turn so + // pre-prompt output (banner, status lines) isn't dropped. + const id = nextTurnId(state.turns); + const newTurn: Turn = { + id, + bytes: event.bytes, + status: "live", + }; + return { ...state, turns: [...state.turns, newTurn] }; + } + const current = state.turns[liveIdx]; + const merged: Turn = { + ...current, + bytes: concatBytes(current.bytes, event.bytes), + }; + return { ...state, turns: replaceTurn(state.turns, liveIdx, merged) }; + } + case "hook": { + switch (event.kind) { + case "prompt_submitted": { + // Close any live turn, open a fresh one, and clear the + // awaiting badge in one shot. + const liveIdx = liveTurnIndex(state.turns); + const closed = + liveIdx === -1 + ? state.turns + : replaceTurn(state.turns, liveIdx, { + ...state.turns[liveIdx], + status: "done", + }); + const newTurn: Turn = { + id: nextTurnId(closed), + bytes: new Uint8Array(0), + status: "live", + }; + return { + ...state, + turns: [...closed, newTurn], + awaitingInput: false, + }; + } + case "stop": { + const liveIdx = liveTurnIndex(state.turns); + if (liveIdx === -1) return state; + return { + ...state, + turns: replaceTurn(state.turns, liveIdx, { + ...state.turns[liveIdx], + status: "done", + }), + }; + } + case "awaiting": { + // Idempotent — preserve referential equality on duplicates + // so React can bail on the re-render. + if (state.awaitingInput) return state; + return { ...state, awaitingInput: true }; + } + case "subagent_stop": { + // v1: intentionally a no-op. The orchestrator's own `stop` + // is what closes the visible turn; subagent boundaries are + // not surfaced in the assembled turn list yet. + return state; + } + case "unknown": { + // Schema-drift fallback. Surface once via console.warn so + // the kind name is visible in dev tools, but leave the + // visible state alone — adding turns for hooks we don't + // understand would corrupt the assembled view. + if (event.reason !== undefined) { + console.warn( + "[interactive] ignoring unknown hook kind:", + event.reason, + ); + } else { + console.warn("[interactive] ignoring unknown hook (no label)"); + } + return state; + } + } + // Exhaustiveness guard — TypeScript ensures every HookKind is + // handled above. Belt-and-braces fallback so the reducer is + // total even if a new kind lands without an updated switch. + return state; + } + case "exit": { + const liveIdx = liveTurnIndex(state.turns); + const turns = + liveIdx === -1 + ? state.turns + : replaceTurn(state.turns, liveIdx, { + ...state.turns[liveIdx], + status: "crashed", + }); + return { ...state, turns, crashed: true }; + } + } +} + +// --------------------------------------------------------------------------- +// React hook +// --------------------------------------------------------------------------- + +export interface UseInteractiveTurnAssembler { + turns: Turn[]; + awaitingInput: boolean; + crashed: boolean; +} + +/** + * Subscribe to the three G3 event streams for `sid` and assemble them + * into a flat list of turns plus the `awaitingInput` / `crashed` + * aux state. Passing `null` (or switching it later) tears down any + * existing subscriptions and resets to the initial state — useful when + * the chat panel deselects. + */ +export function useInteractiveTurnAssembler( + sid: string | null, +): UseInteractiveTurnAssembler { + const [state, dispatch] = useReducer(assemblerReducer, initialAssemblerState); + + useEffect(() => { + if (!sid) return; + // We can't await inside `useEffect`, so the cleanup may run before + // the three `subscribe*` promises resolve. Track that with a flag + // and invoke each unlisten as soon as it becomes available; this + // keeps us from leaking a listener if the effect tears down + // mid-handshake. + let cancelled = false; + const unlistens: (() => void)[] = []; + + const register = (unlisten: () => void) => { + if (cancelled) { + unlisten(); + return; + } + unlistens.push(unlisten); + }; + + void subscribeOutput(sid, (ev) => { + dispatch({ + type: "output", + bytes: base64ToBytes(ev.bytesB64), + seq: ev.seq, + }); + }).then(register); + + void subscribeHooks(sid, (ev) => { + dispatch({ type: "hook", kind: ev.kind, reason: ev.reason }); + }).then(register); + + void subscribeExit(sid, (ev) => { + dispatch({ type: "exit", reason: ev.reason }); + }).then(register); + + return () => { + cancelled = true; + for (const u of unlistens) u(); + }; + }, [sid]); + + return state; +} From 7b2ebeba843d1e35c4fb3f06717ac7dbb43c12e6 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 22:57:47 -0700 Subject: [PATCH 47/95] fix(ui): reset turn assembler state on sid change; pin turn-id numbering --- .../hooks/useInteractiveTurnAssembler.test.ts | 110 +++++++++++++++++- .../src/hooks/useInteractiveTurnAssembler.ts | 18 ++- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/ui/src/hooks/useInteractiveTurnAssembler.test.ts b/src/ui/src/hooks/useInteractiveTurnAssembler.test.ts index c31f3648e..507c2a158 100644 --- a/src/ui/src/hooks/useInteractiveTurnAssembler.test.ts +++ b/src/ui/src/hooks/useInteractiveTurnAssembler.test.ts @@ -47,22 +47,46 @@ function reduce( // --------------------------------------------------------------------------- describe("useInteractiveTurnAssembler reducer", () => { - it("emits a turn on Stop", () => { + it("emits a turn on Stop with the spec-defined id numbering", () => { + // Feed pre-prompt output first so the transient "turn 0" exists, + // then run the prompt → output → stop sequence. That way the + // post-prompt turn is unambiguously id=1, which pins the contract + // (transient pre-prompt turn = 0; first user-submitted turn = 1). const state = reduce(initialAssemblerState, [ + output("welcome banner"), { type: "hook", kind: "prompt_submitted" }, output("hello "), output("world"), { type: "hook", kind: "stop" }, ]); - expect(state.turns).toHaveLength(1); + expect(state.turns).toHaveLength(2); expect(state.turns[0].id).toBe(0); - expect(state.turns[0].status).toBe("done"); - expect(decode(state.turns[0].bytes)).toBe("hello world"); + expect(state.turns[0].status).toBe("done"); // closed by prompt_submitted + expect(decode(state.turns[0].bytes)).toBe("welcome banner"); + expect(state.turns[1].id).toBe(1); + expect(state.turns[1].status).toBe("done"); + expect(decode(state.turns[1].bytes)).toBe("hello world"); expect(state.awaitingInput).toBe(false); expect(state.crashed).toBe(false); }); + it("resets accumulated state on a reset event", () => { + const before = reduce(initialAssemblerState, [ + { type: "hook", kind: "prompt_submitted" }, + output("hello"), + { type: "hook", kind: "awaiting" }, + ]); + expect(before.turns).toHaveLength(1); + expect(before.awaitingInput).toBe(true); + + const after = assemblerReducer(before, { type: "reset" }); + expect(after).toBe(initialAssemblerState); + expect(after.turns).toEqual([]); + expect(after.awaitingInput).toBe(false); + expect(after.crashed).toBe(false); + }); + it("preserves pre-prompt output as a transient turn 0", () => { // The very first output before any `prompt_submitted` should not // be dropped — it carries Claude's splash / banner. @@ -300,4 +324,82 @@ describe("useInteractiveTurnAssembler hook wiring", () => { expect(u).toHaveBeenCalledTimes(1); } }); + + it("resets accumulated state when sid changes", async () => { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + const React = await import("react"); + const { useInteractiveTurnAssembler } = await import( + "./useInteractiveTurnAssembler" + ); + + let capturedState: ReturnType | null = + null; + + function Probe({ sid }: { sid: string | null }) { + capturedState = useInteractiveTurnAssembler(sid); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + try { + // Mount with sid-A and accumulate a finished turn. + await act(async () => { + root.render(React.createElement(Probe, { sid: "sid-A" })); + }); + + expect(harness.outputHandlers).toHaveLength(1); + expect(harness.hookHandlers).toHaveLength(1); + + await act(async () => { + harness.hookHandlers[0]({ + sid: "sid-A", + kind: "prompt_submitted", + }); + // "ok" -> base64 "b2s=" + harness.outputHandlers[0]({ + sid: "sid-A", + bytesB64: "b2s=", + seq: 1, + }); + harness.hookHandlers[0]({ sid: "sid-A", kind: "stop" }); + }); + + { + const state = capturedState as unknown as AssemblerState; + expect(state.turns).toHaveLength(1); + expect(decode(state.turns[0].bytes)).toBe("ok"); + } + + // Re-render with sid-B. The hook should dispatch a reset before + // re-subscribing, so the assembled turn list goes back to empty. + await act(async () => { + root.render(React.createElement(Probe, { sid: "sid-B" })); + }); + + { + const state = capturedState as unknown as AssemblerState; + expect(state.turns).toEqual([]); + expect(state.awaitingInput).toBe(false); + expect(state.crashed).toBe(false); + } + + // Re-render with null (deselect) — state stays empty. + await act(async () => { + root.render(React.createElement(Probe, { sid: null })); + }); + + { + const state = capturedState as unknown as AssemblerState; + expect(state.turns).toEqual([]); + } + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + } + }); }); diff --git a/src/ui/src/hooks/useInteractiveTurnAssembler.ts b/src/ui/src/hooks/useInteractiveTurnAssembler.ts index 9f483fec0..40c425a5e 100644 --- a/src/ui/src/hooks/useInteractiveTurnAssembler.ts +++ b/src/ui/src/hooks/useInteractiveTurnAssembler.ts @@ -68,7 +68,8 @@ export interface AssemblerState { export type AssemblerEvent = | { type: "output"; bytes: Uint8Array; seq: number } | { type: "hook"; kind: HookKind; reason?: string } - | { type: "exit"; reason: string }; + | { type: "exit"; reason: string } + | { type: "reset" }; export const initialAssemblerState: AssemblerState = { turns: [], @@ -256,6 +257,13 @@ export function assemblerReducer( }); return { ...state, turns, crashed: true }; } + case "reset": { + // Drop all accumulated turns + aux state. Used by the hook when + // `sid` changes so events from a stale session don't bleed into + // the next one. Returns a fresh reference even when state is + // already empty so callers can rely on the dispatch landing. + return initialAssemblerState; + } } } @@ -281,6 +289,14 @@ export function useInteractiveTurnAssembler( ): UseInteractiveTurnAssembler { const [state, dispatch] = useReducer(assemblerReducer, initialAssemblerState); + // Reset accumulated turns / aux state whenever `sid` changes (including + // the `sid -> null` deselect case). The subscription effect below + // re-registers listeners for the new sid; without this, the previous + // session's turns would remain visible after the switch. + useEffect(() => { + dispatch({ type: "reset" }); + }, [sid]); + useEffect(() => { if (!sid) return; // We can't await inside `useEffect`, so the cleanup may run before From 18cf34f91979fcbc3ee46403ada825f469f28684 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 23:02:19 -0700 Subject: [PATCH 48/95] feat(ui): InteractiveTurnView renders per-turn xterm.js Adds a small embedded xterm.js component used by interactive (tmux/sidecar) Claude sessions to render each assembled turn's bytes in the chat panel. Reuses getTerminalTheme() so colors stay token-driven and disables stdin / the cursor since each turn is read-only. Co-Authored-By: Claude Opus 4.7 --- .../chat/InteractiveTurnView.module.css | 23 ++++ .../chat/InteractiveTurnView.test.tsx | 118 ++++++++++++++++++ .../components/chat/InteractiveTurnView.tsx | 100 +++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 src/ui/src/components/chat/InteractiveTurnView.module.css create mode 100644 src/ui/src/components/chat/InteractiveTurnView.test.tsx create mode 100644 src/ui/src/components/chat/InteractiveTurnView.tsx diff --git a/src/ui/src/components/chat/InteractiveTurnView.module.css b/src/ui/src/components/chat/InteractiveTurnView.module.css new file mode 100644 index 000000000..cde6f3412 --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTurnView.module.css @@ -0,0 +1,23 @@ +/* Per-turn embedded xterm.js view. Sized via xterm.js's own viewport + * (rows/cols passed in as props); we just give the host container a + * predictable layout so adjacent turns stack cleanly in the chat panel. + */ + +.interactiveTurnView { + width: 100%; + display: block; + background: var(--terminal-bg); + color: var(--terminal-fg); + border: 1px solid var(--divider); + border-radius: 4px; + padding: 4px; + overflow: hidden; +} + +/* xterm.js writes its DOM into a child .xterm element; let it claim the + * full width of our host so the renderer's column math matches what the + * FitAddon measures. + */ +.interactiveTurnView :global(.xterm) { + width: 100%; +} diff --git a/src/ui/src/components/chat/InteractiveTurnView.test.tsx b/src/ui/src/components/chat/InteractiveTurnView.test.tsx new file mode 100644 index 000000000..cb7c4360c --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTurnView.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import { InteractiveTurnView } from "./InteractiveTurnView"; + +// xterm.js paints asynchronously into the DOM, so the test polls until +// the expected text appears (or a short timeout elapses). Mirrors the +// `waitFor` helper from @testing-library/react without pulling in that +// dependency. +async function waitFor( + predicate: () => boolean, + { timeoutMs = 2000, intervalMs = 16 }: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + if (!predicate()) { + throw new Error("waitFor predicate never satisfied"); + } +} + +const mountedRoots: Root[] = []; +const mountedContainers: HTMLElement[] = []; + +async function render(node: ReactNode): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push(root); + mountedContainers.push(container); + await act(async () => { + root.render(node); + }); + return container; +} + +afterEach(async () => { + for (const root of mountedRoots.splice(0).reverse()) { + await act(async () => { + root.unmount(); + }); + } + for (const container of mountedContainers.splice(0)) { + container.remove(); + } +}); + +describe("InteractiveTurnView", () => { + it("writes incoming bytes into xterm.js", async () => { + const container = await render( + , + ); + + await waitFor(() => (container.textContent ?? "").includes("hello")); + expect(container.textContent ?? "").toContain("hello"); + }); + + it("creates the xterm host element", async () => { + const container = await render( + , + ); + + // The xterm.js renderer drops a `.xterm` element into the host the + // moment `term.open()` runs. Use it as a synchronous-mount sentinel + // so we know `useEffect` did its work before unmount. + await waitFor(() => container.querySelector(".xterm") !== null); + expect(container.querySelector(".xterm")).not.toBeNull(); + }); + + it("disposes the terminal on unmount without throwing", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + , + ); + }); + // Unmount should run xterm's `dispose()` cleanly — assert that the + // host element is empty afterwards (xterm clears its DOM on dispose + // through React's child-unmount flow). + await act(async () => { + root.unmount(); + }); + container.remove(); + expect(true).toBe(true); + }); + + it("appends new bytes when the prop reference changes", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push(root); + mountedContainers.push(container); + + await act(async () => { + root.render( + , + ); + }); + await waitFor(() => (container.textContent ?? "").includes("first")); + + await act(async () => { + root.render( + , + ); + }); + await waitFor(() => (container.textContent ?? "").includes("second")); + expect(container.textContent ?? "").toContain("second"); + }); +}); diff --git a/src/ui/src/components/chat/InteractiveTurnView.tsx b/src/ui/src/components/chat/InteractiveTurnView.tsx new file mode 100644 index 000000000..ea6e45fe0 --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTurnView.tsx @@ -0,0 +1,100 @@ +// Per-turn embedded xterm.js view for interactive (tmux/sidecar) Claude +// sessions. +// +// G6 renders one of these per assembled `Turn` (see +// `hooks/useInteractiveTurnAssembler.ts`) inside the chat panel. Each +// instance owns a small xterm.js terminal that only ever receives the +// bytes belonging to that turn — there is no PTY attached, no input +// forwarding, and no resize wiring beyond the initial FitAddon pass. +// The xterm.js viewport is intentionally fixed-size so adjacent turns +// stack predictably; the host (ChatPanel) decides how to lay them out. +// +// Theme colors come from `getTerminalTheme()` which reads the CSS custom +// properties on `` — keeps these turn views in lockstep with the +// rest of the app (and out of `bun run lint:css`'s way). + +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { getTerminalTheme } from "../../utils/theme"; +import "@xterm/xterm/css/xterm.css"; +import styles from "./InteractiveTurnView.module.css"; + +interface InteractiveTurnViewProps { + bytes: Uint8Array; + rows?: number; + cols?: number; +} + +export function InteractiveTurnView({ + bytes, + rows = 24, + cols = 80, +}: InteractiveTurnViewProps) { + const containerRef = useRef(null); + const termRef = useRef(null); + + // Mount/unmount the terminal. We intentionally rebuild the instance if + // `rows`/`cols` change because xterm.js's `resize()` triggers extra + // renderer work that's pointless for a static per-turn view — the + // chat host wires those dimensions once when the turn is created. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const monoFont = + getComputedStyle(document.documentElement) + .getPropertyValue("--font-mono") + .trim() || "monospace"; + + const term = new Terminal({ + rows, + cols, + fontFamily: monoFont, + theme: getTerminalTheme(), + // Per-turn views are read-only — disable the cursor so a frozen + // turn doesn't pretend it's accepting keystrokes. + cursorBlink: false, + cursorStyle: "block", + disableStdin: true, + // xterm.js's unicode subsystem is reached behind the proposed-API + // flag; matching TerminalPanel here keeps width tables consistent + // even though we don't switch to Unicode 11 in this read-only view. + allowProposedApi: true, + scrollback: 0, + }); + + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(container); + // FitAddon needs the host to have a real width before it can compute + // cell dimensions. In jsdom/happy-dom layout returns zeros, in which + // case `fit()` is a no-op — the constructor's explicit rows/cols + // already produced a usable terminal. + try { + fit.fit(); + } catch { + // Swallow: a missing renderer dimension in tests/headless DOM + // shouldn't tear the component down. + } + + termRef.current = term; + return () => { + term.dispose(); + termRef.current = null; + }; + }, [rows, cols]); + + // Write incoming bytes whenever they change. We always start from a + // fresh terminal on mount, so a parent that swaps in a brand-new + // `bytes` reference will see the full payload replayed. For "append" + // semantics on a live turn, the parent should pass the cumulative + // byte buffer — that's the contract G6 uses. + useEffect(() => { + const term = termRef.current; + if (!term) return; + term.write(bytes); + }, [bytes]); + + return
; +} From 5ccd780b0d8d6d389965a786f921ca96f4a54d82 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 23:05:32 -0700 Subject: [PATCH 49/95] fix(ui): retain InteractiveTurnView bytes across resize; assert dispose --- .../chat/InteractiveTurnView.test.tsx | 63 ++++++++++++++++--- .../components/chat/InteractiveTurnView.tsx | 52 +++++++++++++-- 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/src/ui/src/components/chat/InteractiveTurnView.test.tsx b/src/ui/src/components/chat/InteractiveTurnView.test.tsx index cb7c4360c..a225fd0fe 100644 --- a/src/ui/src/components/chat/InteractiveTurnView.test.tsx +++ b/src/ui/src/components/chat/InteractiveTurnView.test.tsx @@ -2,7 +2,8 @@ import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Terminal } from "@xterm/xterm"; import { InteractiveTurnView } from "./InteractiveTurnView"; @@ -72,23 +73,67 @@ describe("InteractiveTurnView", () => { expect(container.querySelector(".xterm")).not.toBeNull(); }); - it("disposes the terminal on unmount without throwing", async () => { + it("disposes the terminal on unmount", async () => { + // Spy on xterm's prototype `dispose` so we can verify the cleanup + // path actually invoked it. The previous version of this test only + // asserted `true === true`, which made it impossible for a + // regression in the unmount effect to fail the suite. + const disposeSpy = vi.spyOn(Terminal.prototype, "dispose"); + try { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + , + ); + }); + const callsBeforeUnmount = disposeSpy.mock.calls.length; + await act(async () => { + root.unmount(); + }); + container.remove(); + expect(disposeSpy.mock.calls.length).toBeGreaterThan(callsBeforeUnmount); + } finally { + disposeSpy.mockRestore(); + } + }); + + it("retains the rendered bytes when rows/cols change", async () => { + // Regression: the mount effect re-creates the xterm instance when + // rows/cols change, so we must replay the accumulated `bytes` into + // the fresh terminal — otherwise the new terminal renders empty + // until the parent next mutates `bytes`. const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + mountedRoots.push(root); + mountedContainers.push(container); + + const payload = new TextEncoder().encode("preserved-on-resize\r\n"); + await act(async () => { root.render( - , + , ); }); - // Unmount should run xterm's `dispose()` cleanly — assert that the - // host element is empty afterwards (xterm clears its DOM on dispose - // through React's child-unmount flow). + await waitFor(() => + (container.textContent ?? "").includes("preserved-on-resize"), + ); + + // Change rows/cols WITHOUT changing bytes. The same `payload` + // reference is passed so the bytes effect's dependency doesn't + // change — the only thing forcing work is the rows/cols remount. await act(async () => { - root.unmount(); + root.render( + , + ); }); - container.remove(); - expect(true).toBe(true); + + await waitFor(() => + (container.textContent ?? "").includes("preserved-on-resize"), + ); + expect(container.textContent ?? "").toContain("preserved-on-resize"); }); it("appends new bytes when the prop reference changes", async () => { diff --git a/src/ui/src/components/chat/InteractiveTurnView.tsx b/src/ui/src/components/chat/InteractiveTurnView.tsx index ea6e45fe0..791984db4 100644 --- a/src/ui/src/components/chat/InteractiveTurnView.tsx +++ b/src/ui/src/components/chat/InteractiveTurnView.tsx @@ -33,11 +33,24 @@ export function InteractiveTurnView({ }: InteractiveTurnViewProps) { const containerRef = useRef(null); const termRef = useRef(null); + // Tracks how many bytes from `bytes` have already been written into + // the live xterm instance. We use this to (a) replay the accumulated + // buffer when the terminal is recreated on a rows/cols change, and + // (b) write only the new tail when `bytes` grows. + const lastWrittenLenRef = useRef(0); + // The bytes-effect needs the current value of `bytes` but we don't + // want it to re-run on every prop change just to capture the latest — + // a ref keeps the mount effect in sync with whatever the latest + // `bytes` prop is at the moment it runs. + const bytesRef = useRef(bytes); + bytesRef.current = bytes; // Mount/unmount the terminal. We intentionally rebuild the instance if // `rows`/`cols` change because xterm.js's `resize()` triggers extra // renderer work that's pointless for a static per-turn view — the // chat host wires those dimensions once when the turn is created. + // After remount we replay the accumulated `bytes` so a resize doesn't + // wipe the turn's contents. useEffect(() => { const container = containerRef.current; if (!container) return; @@ -78,22 +91,49 @@ export function InteractiveTurnView({ // shouldn't tear the component down. } + // Replay the current accumulated buffer into the freshly-created + // terminal. Without this, a rows/cols change would leave the new + // terminal empty until the parent next mutated `bytes`. + const current = bytesRef.current; + if (current.length > 0) { + term.write(current); + } + lastWrittenLenRef.current = current.length; + termRef.current = term; return () => { term.dispose(); termRef.current = null; + lastWrittenLenRef.current = 0; }; }, [rows, cols]); - // Write incoming bytes whenever they change. We always start from a - // fresh terminal on mount, so a parent that swaps in a brand-new - // `bytes` reference will see the full payload replayed. For "append" - // semantics on a live turn, the parent should pass the cumulative - // byte buffer — that's the contract G6 uses. + // Write incoming bytes whenever they change. The mount effect already + // replays the accumulated buffer when the terminal is (re)created, so + // here we only ever emit the new tail — except when the parent swaps + // in a shorter buffer (a brand-new turn payload), in which case we + // clear and rewrite from scratch. useEffect(() => { const term = termRef.current; if (!term) return; - term.write(bytes); + const lastLen = lastWrittenLenRef.current; + if (bytes.length > lastLen) { + // Common case: parent appended more bytes to the cumulative + // buffer. Write only the new tail to avoid re-rendering the whole + // turn on every chunk. + term.write(lastLen === 0 ? bytes : bytes.subarray(lastLen)); + lastWrittenLenRef.current = bytes.length; + } else if (bytes.length < lastLen) { + // Parent replaced the buffer with something shorter — treat as a + // fresh payload and rewrite from scratch. + term.clear(); + term.reset(); + term.write(bytes); + lastWrittenLenRef.current = bytes.length; + } + // Equal-length case: nothing new to write. (If a parent ever passes + // a same-length-but-different buffer, that's outside this view's + // contract — bytes are expected to be append-only or replaced.) }, [bytes]); return
; From 234aa9a130126c4c76264c3bd0e8614ffd52e9d2 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 23:18:16 -0700 Subject: [PATCH 50/95] feat(ui): wire ChatPanel to interactive backend (embedded + full-terminal modes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G6: ChatPanel now routes between three render paths instead of always showing classic `claude --print` turns. The active backend's `effectiveHarness` decides at render time: - `claude_code` (and every other harness): unchanged `MessagesWithTurns` rendering — the regression baseline pinned by `useInteractiveChatMode.test.tsx`. - `claude_interactive`, default mode: a new `InteractiveTurns` sibling component that drives the G4 turn assembler and renders one G5 `InteractiveTurnView` per assembled turn, plus AwaitingPill / CrashedBanner aux affordances. - `claude_interactive`, terminal mode: a new `InteractiveTerminalMode` sibling that mounts a single full-size xterm.js, wires `subscribeOutput` for the render stream and forwards keystrokes to `sendInput` so the user can drive Claude verbatim. Per-workspace state for the mode toggle lives in `uiSlice` as `interactiveTerminalModeByWorkspace`. The new `InteractiveTerminalModeToggle` in `WorkspacePanelHeader` only renders when the workspace's effective harness is `claude_interactive`, so the classic chat header stays untouched. ChatPanel's own diff is one import block plus one ternary swap of the same `MessagesWithTurns` JSX — no logic changes inside the props. Co-Authored-By: Claude Opus 4.7 --- src/ui/src/components/chat/ChatPanel.tsx | 10 + .../components/chat/ChatPanelSessionView.tsx | 95 ++++---- .../chat/InteractiveTerminalMode.module.css | 22 ++ .../chat/InteractiveTerminalMode.test.tsx | 126 +++++++++++ .../chat/InteractiveTerminalMode.tsx | 146 ++++++++++++ .../InteractiveTerminalModeToggle.module.css | 33 +++ .../chat/InteractiveTerminalModeToggle.tsx | 48 ++++ .../chat/InteractiveTurns.module.css | 53 +++++ .../components/chat/InteractiveTurns.test.tsx | 144 ++++++++++++ .../src/components/chat/InteractiveTurns.tsx | 81 +++++++ .../chat/useInteractiveChatMode.test.tsx | 214 ++++++++++++++++++ .../components/chat/useInteractiveChatMode.ts | 67 ++++++ .../shared/WorkspacePanelHeader.tsx | 2 + src/ui/src/stores/slices/uiSlice.ts | 29 +++ 14 files changed, 1029 insertions(+), 41 deletions(-) create mode 100644 src/ui/src/components/chat/InteractiveTerminalMode.module.css create mode 100644 src/ui/src/components/chat/InteractiveTerminalMode.test.tsx create mode 100644 src/ui/src/components/chat/InteractiveTerminalMode.tsx create mode 100644 src/ui/src/components/chat/InteractiveTerminalModeToggle.module.css create mode 100644 src/ui/src/components/chat/InteractiveTerminalModeToggle.tsx create mode 100644 src/ui/src/components/chat/InteractiveTurns.module.css create mode 100644 src/ui/src/components/chat/InteractiveTurns.test.tsx create mode 100644 src/ui/src/components/chat/InteractiveTurns.tsx create mode 100644 src/ui/src/components/chat/useInteractiveChatMode.test.tsx create mode 100644 src/ui/src/components/chat/useInteractiveChatMode.ts diff --git a/src/ui/src/components/chat/ChatPanel.tsx b/src/ui/src/components/chat/ChatPanel.tsx index dbc484980..7ba7fcb75 100644 --- a/src/ui/src/components/chat/ChatPanel.tsx +++ b/src/ui/src/components/chat/ChatPanel.tsx @@ -42,6 +42,7 @@ import { useChatPanelAttachments } from "./useChatPanelAttachments"; import { useChatPanelSessionLifecycle } from "./useChatPanelSessionLifecycle"; import { ChatPanelAttachmentOverlays } from "./ChatPanelAttachmentOverlays"; import { ChatPanelSessionView } from "./ChatPanelSessionView"; +import { useInteractiveChatMode } from "./useInteractiveChatMode"; export function ChatPanel() { const { t } = useTranslation("chat"); @@ -224,6 +225,14 @@ export function ChatPanel() { const elapsed = useWorkspaceElapsedSeconds(selectedWorkspaceId, isRunning); + // G6: detect when the active backend's effective harness is the + // ClaudeInteractive runtime so the session view can swap the chat + // scroll body to the embedded turn-list / full-terminal view. + const interactiveMode = useInteractiveChatMode( + selectedWorkspaceId, + activeSessionId, + ); + const { isAtBottom, markUserScrollIntent, @@ -877,6 +886,7 @@ export function ChatPanel() { hasThinking={hasThinking} historyIndexRef={historyIndexRef} historyRef={historyRef} + interactiveMode={interactiveMode} isAtBottom={isAtBottom} isLoadingMore={isLoadingMore} isRemote={isRemote} diff --git a/src/ui/src/components/chat/ChatPanelSessionView.tsx b/src/ui/src/components/chat/ChatPanelSessionView.tsx index c05612b80..1616aad4d 100644 --- a/src/ui/src/components/chat/ChatPanelSessionView.tsx +++ b/src/ui/src/components/chat/ChatPanelSessionView.tsx @@ -25,7 +25,10 @@ import { ChatInputArea } from "./ChatInputArea"; import { ChatSearchBar } from "./ChatSearchBar"; import { CliInvocationBanner } from "./CliInvocationBanner"; import { CurrentTurnTaskProgress } from "./CurrentTurnTaskProgress"; +import { InteractiveTerminalMode } from "./InteractiveTerminalMode"; +import { InteractiveTurns } from "./InteractiveTurns"; import { MessagesWithTurns } from "./MessagesWithTurns"; +import type { useInteractiveChatMode } from "./useInteractiveChatMode"; import { OverlayScrollbar } from "./OverlayScrollbar"; import { PlanApprovalCard } from "./PlanApprovalCard"; import { setPlanModeAndPersist } from "./planModePersistence"; @@ -86,6 +89,7 @@ type ChatPanelSessionViewProps = Pick< error: string | null; historyIndexRef: MutableRefObject; historyRef: MutableRefObject>; + interactiveMode: ReturnType; isAtBottom: boolean; isRemote: boolean; markUserScrollIntent: () => void; @@ -138,6 +142,7 @@ export function ChatPanelSessionView({ hasThinking, historyIndexRef, historyRef, + interactiveMode, isAtBottom, isLoadingMore, isRemote, @@ -234,47 +239,55 @@ export function ChatPanelSessionView({ Loading older messages…
)} - {activeSessionId && selectedWorkspaceId && ( - 0 ? ( - - ) : null - } - streamingThinkingNode={ - hasThinking && showThinkingBlocks ? ( - - ) : null - } - streamingMessageNode={ - hasStreaming || hasPendingTypewriter ? ( - - ) : null - } - /> - )} + {activeSessionId && + selectedWorkspaceId && + (interactiveMode.isInteractive ? ( + interactiveMode.terminalMode ? ( + + ) : ( + + ) + ) : ( + 0 ? ( + + ) : null + } + streamingThinkingNode={ + hasThinking && showThinkingBlocks ? ( + + ) : null + } + streamingMessageNode={ + hasStreaming || hasPendingTypewriter ? ( + + ) : null + } + /> + ))} {showChatAuthLoginPanel && ( undefined); +const sendInputMock = vi.fn(async (_sid: string, _text: string) => undefined); +const subscribeOutputUnlistens: Array<() => void> = []; +const subscribeOutputMock = vi.fn( + async (_sid: string, _fn: (ev: unknown) => void) => { + const unlisten = vi.fn(); + subscribeOutputUnlistens.push(unlisten); + return unlisten; + }, +); + +vi.mock("../../services/interactive", () => ({ + attach: (sid: string) => attachMock(sid), + sendInput: (sid: string, text: string) => sendInputMock(sid, text), + subscribeOutput: (sid: string, fn: (ev: unknown) => void) => + subscribeOutputMock(sid, fn), +})); + +const mountedRoots: Root[] = []; +const mountedContainers: HTMLElement[] = []; + +async function render(node: ReactNode): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push(root); + mountedContainers.push(container); + await act(async () => { + root.render(node); + }); + // Let microtasks flush — the component awaits subscribeOutput inside + // a `.then()` chain so the unlisten registration runs one microtask + // after the synchronous render returns. + await act(async () => { + await Promise.resolve(); + }); + return container; +} + +afterEach(async () => { + for (const root of mountedRoots.splice(0).reverse()) { + await act(async () => { + root.unmount(); + }); + } + for (const container of mountedContainers.splice(0)) { + container.remove(); + } + attachMock.mockClear(); + sendInputMock.mockClear(); + subscribeOutputMock.mockClear(); + subscribeOutputUnlistens.length = 0; +}); + +describe("InteractiveTerminalMode", () => { + it("mounts xterm.js and wires the G3 services", async () => { + const container = await render(); + + // xterm.js writes its DOM into a `.xterm` element on `term.open()`. + expect(container.querySelector(".xterm")).not.toBeNull(); + expect(attachMock).toHaveBeenCalledWith("sid-42"); + expect(subscribeOutputMock).toHaveBeenCalledWith( + "sid-42", + expect.any(Function), + ); + }); + + it("releases the output subscription on unmount", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // Allow the subscribeOutput promise to settle and the unlisten to be + // captured by the effect. + await act(async () => { + await Promise.resolve(); + }); + + expect(subscribeOutputUnlistens).toHaveLength(1); + const unlisten = subscribeOutputUnlistens[0]!; + expect(unlisten).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + + expect(unlisten).toHaveBeenCalledTimes(1); + }); + + it("does not crash when unmounted immediately", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + // Unmount before subscribeOutput resolves to exercise the + // `cancelled` branch in the effect. + await act(async () => { + root.unmount(); + }); + container.remove(); + // No assertion needed beyond "did not throw". + expect(true).toBe(true); + }); +}); diff --git a/src/ui/src/components/chat/InteractiveTerminalMode.tsx b/src/ui/src/components/chat/InteractiveTerminalMode.tsx new file mode 100644 index 000000000..513cf1f7f --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTerminalMode.tsx @@ -0,0 +1,146 @@ +// Full-terminal view of an interactive Claude session. +// +// G6's opt-in render mode: the user clicked "Open in Terminal" in the +// chat header, so we swap the per-turn embedded list for one xterm.js +// instance attached to the entire interactive sid. The terminal is +// live — keystrokes are forwarded to the underlying tmux/sidecar host +// via `sendInput`, and `subscribeOutput` mirrors the host's output +// stream into the local viewport. +// +// We intentionally do NOT consume the G4 turn assembler here: the user +// asked for the unmodulated terminal, so the screen flows verbatim. The +// embedded `InteractiveTurns` view is the place that adds chat-style +// chrome. +// +// Theme construction mirrors `InteractiveTurnView` (and TerminalPanel) +// so font / colors stay in lockstep with the rest of the app — the +// FitAddon claims as much vertical space as its host gives it. + +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; + +import { + attach, + sendInput, + subscribeOutput, +} from "../../services/interactive"; +import { base64ToBytes } from "../../utils/base64"; +import { getTerminalTheme } from "../../utils/theme"; +import "@xterm/xterm/css/xterm.css"; +import styles from "./InteractiveTerminalMode.module.css"; + +interface InteractiveTerminalModeProps { + sid: string; +} + +export function InteractiveTerminalMode({ sid }: InteractiveTerminalModeProps) { + const containerRef = useRef(null); + + // One mount per sid — re-attach + re-subscribe when the chat panel + // switches sessions. We capture the latest `sid` via a ref so the + // cleanup branch reads the same value the effect installed against + // (React's effect closure already does this; the ref is defensive + // against future refactors that pull pieces out of the effect). + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const monoFont = + getComputedStyle(document.documentElement) + .getPropertyValue("--font-mono") + .trim() || "monospace"; + + const term = new Terminal({ + fontFamily: monoFont, + theme: getTerminalTheme(), + // Match TerminalPanel: Unicode 11 widths matter for cursor + // alignment under PSReadLine / ConPTY-style line redraws. The + // proposed-API flag is required to flip the active version. + allowProposedApi: true, + cursorBlink: true, + cursorStyle: "block", + // Interactive Claude is full-screen ANSI; let the FitAddon decide + // rows/cols once the host has a layout, instead of pinning small + // defaults that would force scrollback on every spawn. + scrollback: 1000, + }); + + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(container); + try { + fit.fit(); + } catch { + // In jsdom / happy-dom layout is zero so FitAddon throws — swallow + // so the smoke test path can still verify mount/unmount. + } + + // Forward keystrokes to the host. We don't try to translate keys + // ourselves; xterm.js's `onData` already produces the correct + // escape sequences for ANSI control + raw characters. + const dataDisposable = term.onData((data) => { + // Fire-and-forget — surfacing every keystroke failure would drown + // the chat panel in toasts. The host-side state is the source of + // truth and a dropped keystroke is recoverable (user repeats it). + void sendInput(sid, data).catch((err) => { + console.warn("[interactive] sendInput failed:", err); + }); + }); + + // Track effect-teardown so output-stream subscribe promises that + // resolve after unmount are dropped without leaking the listener. + let cancelled = false; + let unlistenOutput: (() => void) | null = null; + + void subscribeOutput(sid, (ev) => { + term.write(base64ToBytes(ev.bytesB64)); + }).then((unlisten) => { + if (cancelled) { + unlisten(); + return; + } + unlistenOutput = unlisten; + }); + + // Re-attach the host so it knows we want to receive output again + // after a reconnect. The Rust side is idempotent — re-attaching a + // running stream is a no-op — so we don't have to track whether + // we've attached before. Errors here are non-fatal; output is + // still wired up via `subscribeOutput`. + void attach(sid).catch((err) => { + console.warn("[interactive] attach failed:", err); + }); + + // Initial fit after mount so the host computes rows/cols from the + // real container dimensions, then a ResizeObserver re-runs `fit()` + // whenever the parent (or window) reshapes us. A small debounce + // mirrors TerminalPanel's pattern and avoids thrashing the + // renderer during a drag. + const ro = new ResizeObserver(() => { + try { + fit.fit(); + } catch { + // Swallow renderer-pre-layout throws — same justification as + // the initial fit above. + } + }); + ro.observe(container); + + return () => { + cancelled = true; + ro.disconnect(); + dataDisposable.dispose(); + if (unlistenOutput) unlistenOutput(); + term.dispose(); + }; + }, [sid]); + + return ( +
+ ); +} diff --git a/src/ui/src/components/chat/InteractiveTerminalModeToggle.module.css b/src/ui/src/components/chat/InteractiveTerminalModeToggle.module.css new file mode 100644 index 000000000..1ad18e35e --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTerminalModeToggle.module.css @@ -0,0 +1,33 @@ +/* Header button for the "Open in Terminal" / "Back to Chat" toggle. + * Sized to match PanelToggles' icon buttons so the chat header stays + * visually consistent — same hit target, same padding rhythm. */ + +.toggle { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + min-width: 24px; + padding: 0 6px; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.toggle:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.toggle:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 1px; +} + +.active { + background: var(--accent-bg); + color: var(--accent-primary); + border-color: var(--accent-bg-strong); +} diff --git a/src/ui/src/components/chat/InteractiveTerminalModeToggle.tsx b/src/ui/src/components/chat/InteractiveTerminalModeToggle.tsx new file mode 100644 index 000000000..d649d60e2 --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTerminalModeToggle.tsx @@ -0,0 +1,48 @@ +// "Open in Terminal" / "Back to Chat" toggle for the chat header. +// +// Surfaces only when the workspace's active backend resolves to the +// ClaudeInteractive harness and there is an active chat session — i.e. +// exactly the conditions under which ChatPanel renders the new +// interactive views. Clicking flips +// `interactiveTerminalModeByWorkspace[workspaceId]` in the Zustand +// store, which ChatPanel observes via `useInteractiveChatMode`. + +import { SquareTerminal, MessageSquare } from "lucide-react"; + +import { useAppStore } from "../../stores/useAppStore"; +import { useInteractiveChatMode } from "./useInteractiveChatMode"; +import styles from "./InteractiveTerminalModeToggle.module.css"; + +export function InteractiveTerminalModeToggle() { + const selectedWorkspaceId = useAppStore((s) => s.selectedWorkspaceId); + const activeSessionId = useAppStore((s) => + s.selectedWorkspaceId + ? (s.selectedSessionIdByWorkspaceId[s.selectedWorkspaceId] ?? null) + : null, + ); + const toggle = useAppStore((s) => s.toggleInteractiveTerminalMode); + const mode = useInteractiveChatMode(selectedWorkspaceId, activeSessionId); + + // Hide entirely outside the interactive code path so the chat header + // doesn't grow a misleading button for classic Claude sessions. + if (!mode.isInteractive || !selectedWorkspaceId || !activeSessionId) { + return null; + } + + const label = mode.terminalMode ? "Back to chat" : "Open in terminal"; + const Icon = mode.terminalMode ? MessageSquare : SquareTerminal; + + return ( + + ); +} diff --git a/src/ui/src/components/chat/InteractiveTurns.module.css b/src/ui/src/components/chat/InteractiveTurns.module.css new file mode 100644 index 000000000..a73defbf0 --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTurns.module.css @@ -0,0 +1,53 @@ +/* Embedded interactive-session view. Container stacks per-turn + * `InteractiveTurnView` cards vertically; aux affordances + * (awaiting / crashed) sit at the tail so they appear next to the + * most-recent turn. */ + +.turns { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.turn { + width: 100%; +} + +/* Status accents — kept subtle so they hint at lifecycle without + * competing with the agent's own ANSI styling inside the turn. + * Theme-token references only (theme.css is the source of raw colors — + * `bun run lint:css` will reject hex/rgba literals anywhere else). + */ +.turnLive { + /* Live turn: nothing special yet — defer to the InteractiveTurnView's + * own border so the user doesn't see a "loading" frame they can't act + * on. */ +} + +.turnDone { + opacity: 0.92; +} + +.turnCrashed { + opacity: 0.7; +} + +.awaitingPill { + align-self: flex-start; + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + background: var(--accent-bg); + color: var(--accent-primary); + border: 1px solid var(--accent-bg-strong); +} + +.crashedBanner { + font-size: 12px; + padding: 6px 10px; + border-radius: 4px; + background: var(--error-bg); + color: var(--text-primary); + border: 1px solid var(--error-border); +} diff --git a/src/ui/src/components/chat/InteractiveTurns.test.tsx b/src/ui/src/components/chat/InteractiveTurns.test.tsx new file mode 100644 index 000000000..c6fc7820e --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTurns.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom + +// Test that `InteractiveTurns` renders one `InteractiveTurnView` per +// turn from the assembler hook and surfaces the awaiting / crashed +// aux indicators conditionally. The assembler hook is mocked so we +// don't have to wire up the G3 Tauri subscriptions in jsdom. + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { InteractiveTurns } from "./InteractiveTurns"; +import type { UseInteractiveTurnAssembler } from "../../hooks/useInteractiveTurnAssembler"; + +// Mock the assembler hook so we drive the rendered output directly. +const mockReturnRef = { current: emptyState() }; +vi.mock("../../hooks/useInteractiveTurnAssembler", () => ({ + useInteractiveTurnAssembler: () => mockReturnRef.current, +})); + +// Stub the InteractiveTurnView so the test asserts on a stable DOM +// surface (a `
`) rather than depending on
+// xterm.js's async renderer landing bytes inside happy-dom.
+vi.mock("./InteractiveTurnView", () => ({
+  InteractiveTurnView: ({ bytes }: { bytes: Uint8Array }) => (
+    
{new TextDecoder().decode(bytes)}
+ ), +})); + +function emptyState(): UseInteractiveTurnAssembler { + return { turns: [], awaitingInput: false, crashed: false }; +} + +const mountedRoots: Root[] = []; +const mountedContainers: HTMLElement[] = []; + +async function render(node: ReactNode): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push(root); + mountedContainers.push(container); + await act(async () => { + root.render(node); + }); + return container; +} + +beforeEach(() => { + mockReturnRef.current = emptyState(); +}); + +afterEach(async () => { + for (const root of mountedRoots.splice(0).reverse()) { + await act(async () => { + root.unmount(); + }); + } + for (const container of mountedContainers.splice(0)) { + container.remove(); + } +}); + +describe("InteractiveTurns", () => { + it("renders one InteractiveTurnView per turn", async () => { + mockReturnRef.current = { + turns: [ + { + id: 0, + bytes: new TextEncoder().encode("first"), + status: "done", + }, + { + id: 1, + bytes: new TextEncoder().encode("second"), + status: "live", + }, + ], + awaitingInput: false, + crashed: false, + }; + + const container = await render(); + const views = container.querySelectorAll('[data-testid="iturnview"]'); + expect(views).toHaveLength(2); + expect(views[0]?.textContent).toBe("first"); + expect(views[1]?.textContent).toBe("second"); + }); + + it("renders the awaiting pill when awaitingInput is true", async () => { + mockReturnRef.current = { + turns: [ + { id: 0, bytes: new Uint8Array(0), status: "live" }, + ], + awaitingInput: true, + crashed: false, + }; + + const container = await render(); + expect( + container.querySelector('[data-testid="interactive-awaiting-pill"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="interactive-crashed-banner"]'), + ).toBeNull(); + }); + + it("omits the awaiting pill when awaitingInput is false", async () => { + mockReturnRef.current = { + turns: [ + { id: 0, bytes: new Uint8Array(0), status: "live" }, + ], + awaitingInput: false, + crashed: false, + }; + + const container = await render(); + expect( + container.querySelector('[data-testid="interactive-awaiting-pill"]'), + ).toBeNull(); + }); + + it("renders the crashed banner when crashed is true", async () => { + mockReturnRef.current = { + turns: [ + { id: 0, bytes: new Uint8Array(0), status: "crashed" }, + ], + awaitingInput: false, + crashed: true, + }; + + const container = await render(); + expect( + container.querySelector('[data-testid="interactive-crashed-banner"]'), + ).not.toBeNull(); + }); + + it("renders nothing-but-container when there are no turns and no aux state", async () => { + const container = await render(); + const root = container.querySelector('[data-testid="interactive-turns"]'); + expect(root).not.toBeNull(); + expect(root?.children).toHaveLength(0); + }); +}); diff --git a/src/ui/src/components/chat/InteractiveTurns.tsx b/src/ui/src/components/chat/InteractiveTurns.tsx new file mode 100644 index 000000000..db2fa731e --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTurns.tsx @@ -0,0 +1,81 @@ +// Embedded (in-chat) view of an interactive Claude session. +// +// G6's default render path for workspaces whose effective harness is +// `claude_interactive`. We feed the live event streams through the +// hook-delimited turn assembler (G4) and render one `InteractiveTurnView` +// (G5) per assembled `Turn` so the chat scroll history matches the way +// users already read traditional Claude Code sessions: each +// prompt → response pair gets its own bordered card, oldest at the top, +// newest at the bottom. +// +// Two small affordances also live here so ChatPanel's diff stays +// minimal (it's the god file from the CLAUDE.md list): +// - `AwaitingPill` — non-blocking status hint when Claude is waiting +// on a user-facing question (`AskUserQuestion` / `ExitPlanMode`). +// - `CrashedBanner` — sticky error when the underlying tmux/sidecar +// session exited unexpectedly. Bare-bones for v1; G9 / G10 will +// replace it with a richer recovery affordance. + +import { useInteractiveTurnAssembler } from "../../hooks/useInteractiveTurnAssembler"; +import type { Turn } from "../../hooks/useInteractiveTurnAssembler"; +import { InteractiveTurnView } from "./InteractiveTurnView"; +import styles from "./InteractiveTurns.module.css"; + +interface InteractiveTurnsProps { + sid: string; +} + +/** Map a `Turn.status` to the per-turn wrapper class. Kept explicit so a + * future status (e.g. `"compacted"`) lands as a compile error rather + * than a silent fallthrough to the default styling. */ +function statusClass(status: Turn["status"]): string { + switch (status) { + case "live": + return styles.turnLive; + case "done": + return styles.turnDone; + case "crashed": + return styles.turnCrashed; + } +} + +function AwaitingPill() { + return ( +
+ Waiting on your input… +
+ ); +} + +function CrashedBanner() { + return ( +
+ Interactive session ended unexpectedly. +
+ ); +} + +export function InteractiveTurns({ sid }: InteractiveTurnsProps) { + const { turns, awaitingInput, crashed } = useInteractiveTurnAssembler(sid); + + return ( +
+ {turns.map((turn) => ( +
+ +
+ ))} + {awaitingInput && } + {crashed && } +
+ ); +} diff --git a/src/ui/src/components/chat/useInteractiveChatMode.test.tsx b/src/ui/src/components/chat/useInteractiveChatMode.test.tsx new file mode 100644 index 000000000..1c34e5f2d --- /dev/null +++ b/src/ui/src/components/chat/useInteractiveChatMode.test.tsx @@ -0,0 +1,214 @@ +// @vitest-environment happy-dom + +// Tests for the ChatPanel router hook. The hook is small but it +// guards the whole interactive-vs-classic render fork, so the matrix +// it returns has to be pinned by tests: +// - When no backend matches the session's provider → fall back to +// `claude_code` (the safe classic default). This is the +// regression guard for ChatPanel — a mid-hydration store must not +// trigger the new render path. +// - When the matching backend's effective harness is +// `claude_interactive`, `isInteractive` flips to true. +// - `terminalMode` only ever surfaces true when the workspace flag +// is set AND the harness is interactive. + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { useInteractiveChatMode } from "./useInteractiveChatMode"; +import type { InteractiveChatMode } from "./useInteractiveChatMode"; +import { useAppStore } from "../../stores/useAppStore"; +import type { AgentBackendConfig } from "../../services/tauri/agentBackends"; + +function makeBackend( + overrides: Partial = {}, +): AgentBackendConfig { + return { + id: "anthropic", + label: "Anthropic", + kind: "anthropic", + base_url: null, + enabled: true, + default_model: null, + manual_models: [], + discovered_models: [], + auth_ref: null, + capabilities: { + thinking: false, + effort: false, + fast_mode: false, + one_m_context: false, + tools: true, + vision: true, + }, + context_window_default: 200000, + model_discovery: false, + has_secret: false, + runtime_harness: undefined, + ...overrides, + }; +} + +// Mirror the manual-mount renderHook pattern used in +// `useInteractiveTurnAssembler.test.ts` — happy-dom/jsdom have no +// `@testing-library/react` in this codebase, so we capture the latest +// hook value via a probe component. +const mountedRoots: Root[] = []; +const mountedContainers: HTMLElement[] = []; + +async function captureMode( + workspaceId: string | null, + sessionId: string | null, +): Promise { + let captured: InteractiveChatMode | null = null; + function Probe() { + captured = useInteractiveChatMode(workspaceId, sessionId); + return null; + } + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push(root); + mountedContainers.push(container); + await act(async () => { + root.render(); + }); + if (captured === null) { + throw new Error("Probe never rendered useInteractiveChatMode"); + } + return captured; +} + +beforeEach(() => { + useAppStore.setState({ + agentBackends: [], + defaultAgentBackendId: "anthropic", + selectedModelProvider: {}, + claudeInteractiveEnabled: false, + interactiveTerminalModeByWorkspace: {}, + }); +}); + +afterEach(async () => { + for (const root of mountedRoots.splice(0).reverse()) { + await act(async () => { + root.unmount(); + }); + } + for (const container of mountedContainers.splice(0)) { + container.remove(); + } +}); + +describe("useInteractiveChatMode", () => { + it("defaults to claude_code when the backend is missing", async () => { + const mode = await captureMode("ws-1", "sess-1"); + expect(mode.harness).toBe("claude_code"); + expect(mode.isInteractive).toBe(false); + expect(mode.terminalMode).toBe(false); + }); + + it("returns claude_code for an Anthropic backend without harness override", async () => { + useAppStore.setState({ + agentBackends: [makeBackend({ id: "anthropic", kind: "anthropic" })], + selectedModelProvider: { "sess-1": "anthropic" }, + }); + const mode = await captureMode("ws-1", "sess-1"); + expect(mode.isInteractive).toBe(false); + }); + + it("flips isInteractive when the override + experimental flag agree", async () => { + useAppStore.setState({ + agentBackends: [ + makeBackend({ + id: "anthropic", + kind: "anthropic", + runtime_harness: "claude_interactive", + }), + ], + selectedModelProvider: { "sess-1": "anthropic" }, + claudeInteractiveEnabled: true, + }); + const mode = await captureMode("ws-1", "sess-1"); + expect(mode.harness).toBe("claude_interactive"); + expect(mode.isInteractive).toBe(true); + expect(mode.terminalMode).toBe(false); + }); + + it("ignores claude_interactive override when the experimental flag is off", async () => { + useAppStore.setState({ + agentBackends: [ + makeBackend({ + id: "anthropic", + kind: "anthropic", + runtime_harness: "claude_interactive", + }), + ], + selectedModelProvider: { "sess-1": "anthropic" }, + claudeInteractiveEnabled: false, + }); + const mode = await captureMode("ws-1", "sess-1"); + expect(mode.isInteractive).toBe(false); + }); + + it("surfaces terminalMode only when the per-workspace flag is set AND interactive", async () => { + useAppStore.setState({ + agentBackends: [ + makeBackend({ + id: "anthropic", + kind: "anthropic", + runtime_harness: "claude_interactive", + }), + ], + selectedModelProvider: { "sess-1": "anthropic" }, + claudeInteractiveEnabled: true, + interactiveTerminalModeByWorkspace: { "ws-1": true }, + }); + const mode = await captureMode("ws-1", "sess-1"); + expect(mode.terminalMode).toBe(true); + }); + + it("never returns terminalMode=true on the classic render path", async () => { + useAppStore.setState({ + agentBackends: [makeBackend({ id: "anthropic", kind: "anthropic" })], + selectedModelProvider: { "sess-1": "anthropic" }, + interactiveTerminalModeByWorkspace: { "ws-1": true }, + }); + const mode = await captureMode("ws-1", "sess-1"); + // Workspace flag is set, but harness is claude_code → terminalMode + // must stay false so a leftover flag doesn't sabotage a classic + // workspace. + expect(mode.terminalMode).toBe(false); + }); + + it("falls back to the default backend when no session-scoped provider is set", async () => { + useAppStore.setState({ + agentBackends: [ + makeBackend({ + id: "anthropic", + kind: "anthropic", + runtime_harness: "claude_interactive", + }), + ], + defaultAgentBackendId: "anthropic", + selectedModelProvider: {}, + claudeInteractiveEnabled: true, + }); + const mode = await captureMode("ws-1", null); + expect(mode.harness).toBe("claude_interactive"); + }); + + it("toggleInteractiveTerminalMode flips the per-workspace flag", () => { + const before = useAppStore.getState().interactiveTerminalModeByWorkspace; + expect(before["ws-1"]).toBeUndefined(); + useAppStore.getState().toggleInteractiveTerminalMode("ws-1"); + expect( + useAppStore.getState().interactiveTerminalModeByWorkspace["ws-1"], + ).toBe(true); + useAppStore.getState().toggleInteractiveTerminalMode("ws-1"); + expect( + useAppStore.getState().interactiveTerminalModeByWorkspace["ws-1"], + ).toBeUndefined(); + }); +}); diff --git a/src/ui/src/components/chat/useInteractiveChatMode.ts b/src/ui/src/components/chat/useInteractiveChatMode.ts new file mode 100644 index 000000000..ffda518db --- /dev/null +++ b/src/ui/src/components/chat/useInteractiveChatMode.ts @@ -0,0 +1,67 @@ +// Helper hook that tells ChatPanel whether to short-circuit its +// classic `claude --print` render path and use the interactive +// (tmux/sidecar) render path instead. +// +// Extracted into its own module so ChatPanel — which is on the +// CLAUDE.md god-file list — stays at "one new import, one new +// conditional render" instead of growing another store-reading +// section. + +import { useAppStore } from "../../stores/useAppStore"; +import { + effectiveHarness, + type AgentBackendRuntimeHarness, +} from "../../services/tauri/agentBackends"; + +export interface InteractiveChatMode { + /** Which harness the workspace's active backend resolves to via + * `effectiveHarness`. Only `"claude_interactive"` triggers the new + * render path; every other value means "render the classic chat + * panel". Exposed so callers can also gate ChatHeader affordances + * ("Open in Terminal" button) on the same value. */ + harness: AgentBackendRuntimeHarness; + /** True only when `harness === "claude_interactive"`. Pre-computed + * here so consumers don't have to remember the magic string. */ + isInteractive: boolean; + /** Per-workspace toggle: when true, render + * `InteractiveTerminalMode` instead of `InteractiveTurns`. Always + * `false` for non-interactive harnesses. */ + terminalMode: boolean; +} + +/** Read the current chat session's effective backend harness from the + * store. Returns the safe "classic" default (`claude_code`) when any + * of the lookups miss — that matches the Rust default and means a + * store mid-hydration can't accidentally flip the chat view. */ +export function useInteractiveChatMode( + workspaceId: string | null, + sessionId: string | null, +): InteractiveChatMode { + const agentBackends = useAppStore((s) => s.agentBackends); + const defaultAgentBackendId = useAppStore((s) => s.defaultAgentBackendId); + const selectedModelProvider = useAppStore((s) => s.selectedModelProvider); + const claudeInteractiveEnabled = useAppStore( + (s) => s.claudeInteractiveEnabled, + ); + const terminalModeMap = useAppStore( + (s) => s.interactiveTerminalModeByWorkspace, + ); + + // Session-scoped provider falls back to the global default (mirrors + // how chat-send resolves the backend in `ChatPanel`). + const providerId = + (sessionId ? selectedModelProvider[sessionId] : null) ?? + defaultAgentBackendId; + const backend = agentBackends.find((b) => b.id === providerId); + + const harness: AgentBackendRuntimeHarness = backend + ? effectiveHarness(backend, { claudeInteractiveEnabled }) + : "claude_code"; + + const isInteractive = harness === "claude_interactive"; + const terminalMode = isInteractive + ? Boolean(workspaceId && terminalModeMap[workspaceId]) + : false; + + return { harness, isInteractive, terminalMode }; +} diff --git a/src/ui/src/components/shared/WorkspacePanelHeader.tsx b/src/ui/src/components/shared/WorkspacePanelHeader.tsx index 947fa73d5..fd4066944 100644 --- a/src/ui/src/components/shared/WorkspacePanelHeader.tsx +++ b/src/ui/src/components/shared/WorkspacePanelHeader.tsx @@ -2,6 +2,7 @@ import { CircleDot, GitBranch } from "lucide-react"; import { useAppStore } from "../../stores/useAppStore"; import { openUrl } from "../../services/tauri"; import { WorkspaceActions } from "../chat/WorkspaceActions"; +import { InteractiveTerminalModeToggle } from "../chat/InteractiveTerminalModeToggle"; import { PanelToggles } from "./PanelToggles"; import { PanelHeader } from "./PanelHeader"; import styles from "./WorkspacePanelHeader.module.css"; @@ -72,6 +73,7 @@ export function WorkspacePanelHeader() { left={left} right={ <> + diff --git a/src/ui/src/stores/slices/uiSlice.ts b/src/ui/src/stores/slices/uiSlice.ts index e098398d9..eff514d34 100644 --- a/src/ui/src/stores/slices/uiSlice.ts +++ b/src/ui/src/stores/slices/uiSlice.ts @@ -155,6 +155,15 @@ export interface UiSlice { setChatInputPrefill: (text: string | null) => void; pendingAttachmentsPrefill: AttachmentInput[] | null; setPendingAttachmentsPrefill: (atts: AttachmentInput[] | null) => void; + + /** Per-workspace toggle that swaps the ChatPanel from the default + * embedded interactive-turn list into a full-size xterm.js view of + * the same interactive session. Only meaningful when the workspace's + * active backend's effective harness is `claude_interactive` — + * ignored by ChatPanel otherwise. Absent key === embedded mode + * (the default). */ + interactiveTerminalModeByWorkspace: Record; + toggleInteractiveTerminalMode: (workspaceId: string) => void; } export const createUiSlice: StateCreator = ( @@ -424,4 +433,24 @@ export const createUiSlice: StateCreator = ( pendingAttachmentsPrefill: null, setPendingAttachmentsPrefill: (atts) => set({ pendingAttachmentsPrefill: atts }), + + interactiveTerminalModeByWorkspace: {}, + toggleInteractiveTerminalMode: (workspaceId) => + set((s) => { + // Mirror the "absence === default" invariant used by `repoCollapsed`: + // flipping back to embedded mode deletes the key rather than writing + // `false`, so the map stays canonical and selectors can use + // `Boolean(...)` without worrying about stale entries. + if (s.interactiveTerminalModeByWorkspace[workspaceId]) { + const next = { ...s.interactiveTerminalModeByWorkspace }; + delete next[workspaceId]; + return { interactiveTerminalModeByWorkspace: next }; + } + return { + interactiveTerminalModeByWorkspace: { + ...s.interactiveTerminalModeByWorkspace, + [workspaceId]: true, + }, + }; + }), }); From 2cf4ddde5046d3335f0121c56f23672f6c470bae Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 23:28:37 -0700 Subject: [PATCH 51/95] feat(ui): sidebar badges for interactive session state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `InteractiveBadge` component rendered next to workspace rows that surfaces interactive-session state ("Awaiting input" / "Detached" / "Crashed"). The badge derives its state from `interactive_sessions.state` rows tracked in a new minimal `interactiveSessionsByWorkspace` store slice; Sidebar.tsx diff is just import + selector + render, keeping the god-file change to ~13 lines. Wired in v1: DB-row state derivation (`computeInteractiveBadgeState`) with crashed > awaiting > detached precedence. Deferred to a follow-up: a data loader that calls `listInteractive(workspaceId)` to populate the slice on workspace open / interactive events, and propagation of the live InteractiveTurnAssembler `awaiting` signal to non-foreground workspaces. Until the loader lands the map is empty and no badge renders — the correct fallback for "workspace without an interactive session". Co-Authored-By: Claude Opus 4.7 --- .../sidebar/InteractiveBadge.test.tsx | 139 ++++++++++++++++ .../components/sidebar/InteractiveBadge.tsx | 153 ++++++++++++++++++ .../src/components/sidebar/Sidebar.module.css | 18 +++ src/ui/src/components/sidebar/Sidebar.tsx | 13 ++ .../stores/slices/interactiveSessionsSlice.ts | 68 ++++++++ src/ui/src/stores/useAppStore.ts | 8 +- 6 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/ui/src/components/sidebar/InteractiveBadge.test.tsx create mode 100644 src/ui/src/components/sidebar/InteractiveBadge.tsx create mode 100644 src/ui/src/stores/slices/interactiveSessionsSlice.ts diff --git a/src/ui/src/components/sidebar/InteractiveBadge.test.tsx b/src/ui/src/components/sidebar/InteractiveBadge.test.tsx new file mode 100644 index 000000000..10be1c9a7 --- /dev/null +++ b/src/ui/src/components/sidebar/InteractiveBadge.test.tsx @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { renderToString } from "react-dom/server"; +import { + InteractiveBadge, + computeInteractiveBadgeState, +} from "./InteractiveBadge"; +import type { InteractiveSessionRow } from "../../services/interactive"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeRow(overrides: Partial = {}): InteractiveSessionRow { + return { + sid: "sid-1", + workspaceId: "ws-1", + hostKind: "tmux", + state: "running", + crashReason: null, + createdAt: "2026-05-16T12:00:00Z", + lastAttachedAt: null, + lastScreenBlob: null, + claudeFlagsJson: "{}", + pid: 1234, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// InteractiveBadge (presentation) +// --------------------------------------------------------------------------- + +describe("InteractiveBadge", () => { + it("renders the awaiting state with the default label and badge-ask token", () => { + const html = renderToString(); + expect(html).toContain("Awaiting input"); + expect(html).toContain('data-interactive-badge-state="awaiting"'); + expect(html).toContain("var(--badge-ask)"); + expect(html).toContain('role="img"'); + expect(html).toContain('aria-label="Awaiting input"'); + expect(html).toContain('title="Awaiting input"'); + }); + + it("renders the detached state with the default label and text-dim token", () => { + const html = renderToString(); + expect(html).toContain("Detached"); + expect(html).toContain('data-interactive-badge-state="detached"'); + expect(html).toContain("var(--text-dim)"); + }); + + it("renders the crashed state with the default label and status-stopped token", () => { + const html = renderToString(); + expect(html).toContain("Crashed"); + expect(html).toContain('data-interactive-badge-state="crashed"'); + expect(html).toContain("var(--status-stopped)"); + }); + + it("honors an override label for title, aria-label, and visible text", () => { + const html = renderToString( + , + ); + expect(html).toContain("En espera"); + expect(html).toContain('title="En espera"'); + expect(html).toContain('aria-label="En espera"'); + // The default label should NOT appear when overridden. + expect(html).not.toContain("Awaiting input"); + }); + + it("passes through a className prop", () => { + const html = renderToString( + , + ); + expect(html).toContain('class="my-extra-class"'); + }); + + it("uses a different data-state attribute per state value", () => { + const a = renderToString(); + const b = renderToString(); + const c = renderToString(); + expect(a).toContain('data-interactive-badge-state="awaiting"'); + expect(b).toContain('data-interactive-badge-state="detached"'); + expect(c).toContain('data-interactive-badge-state="crashed"'); + }); +}); + +// --------------------------------------------------------------------------- +// computeInteractiveBadgeState (selector logic) +// --------------------------------------------------------------------------- + +describe("computeInteractiveBadgeState", () => { + it("returns null when there are no sessions and no live signal", () => { + expect(computeInteractiveBadgeState([])).toBeNull(); + expect(computeInteractiveBadgeState(undefined)).toBeNull(); + }); + + it("returns 'awaiting' when the assembler signals awaiting and no sessions are persisted", () => { + expect(computeInteractiveBadgeState([], true)).toBe("awaiting"); + expect(computeInteractiveBadgeState(undefined, true)).toBe("awaiting"); + }); + + it("returns 'detached' when at least one session is in DB state 'running'", () => { + const rows = [makeRow({ state: "running" })]; + expect(computeInteractiveBadgeState(rows)).toBe("detached"); + }); + + it("returns 'crashed' when any session is in DB state 'crashed' (highest precedence)", () => { + const rows = [ + makeRow({ sid: "a", state: "running" }), + makeRow({ sid: "b", state: "crashed" }), + ]; + expect(computeInteractiveBadgeState(rows)).toBe("crashed"); + }); + + it("returns 'crashed' even when assembler signals awaiting (crash wins)", () => { + const rows = [makeRow({ state: "crashed" })]; + expect(computeInteractiveBadgeState(rows, true)).toBe("crashed"); + }); + + it("returns 'awaiting' when assembler signals awaiting and only running rows exist", () => { + const rows = [makeRow({ state: "running" })]; + expect(computeInteractiveBadgeState(rows, true)).toBe("awaiting"); + }); + + it("returns null when only exited / unknown sessions are persisted", () => { + const rows = [ + makeRow({ sid: "a", state: "exited" }), + makeRow({ sid: "b", state: "unknown" }), + ]; + expect(computeInteractiveBadgeState(rows)).toBeNull(); + }); + + it("ignores non-running, non-crashed states when computing 'detached'", () => { + const rows = [ + makeRow({ sid: "a", state: "exited" }), + makeRow({ sid: "b", state: "running" }), + ]; + expect(computeInteractiveBadgeState(rows)).toBe("detached"); + }); +}); diff --git a/src/ui/src/components/sidebar/InteractiveBadge.tsx b/src/ui/src/components/sidebar/InteractiveBadge.tsx new file mode 100644 index 000000000..878774336 --- /dev/null +++ b/src/ui/src/components/sidebar/InteractiveBadge.tsx @@ -0,0 +1,153 @@ +// G7 — Sidebar badge for interactive-session state. +// +// Surfaces the per-workspace interactive session state on the Sidebar +// workspace row. Pulled into its own file (rather than inlined into the +// already-1700+-line Sidebar god-file, per CLAUDE.md "god files" rule) +// so the Sidebar diff is just `import + render`. +// +// Three states are visualized in v1: +// - "awaiting" — at least one session is parked in an awaiting-input +// state (e.g. tmux session waiting on user input from +// the InteractiveTurnAssembler `awaiting` hook). +// - "detached" — at least one session is alive in the host but not +// currently attached in this Claudette client. Maps to +// `interactive_sessions.state === "running"` (the DB +// stores `running` for both attached and detached +// sessions; v1 treats DB-`running` as "detached" since +// the user-perceived attached state is owned by the +// active ChatPanel, not the row). +// - "crashed" — at least one session is in DB state `"crashed"`. +// +// Wired in G7 v1: DB-row state derived from +// `interactive_sessions.state` (`InteractiveSessionRow.state`). The +// live awaiting-input signal from the per-workspace turn assembler is +// represented via an optional `awaitingFromAssembler` flag — Sidebar +// can pass `true` when the active workspace's assembler is in the +// `awaiting` state. Deferred to a follow-up: assembler awaiting-state +// for *background* workspaces (not the currently open one), and the +// "attached vs detached" distinction (we currently treat any +// `running` DB row as detached for badge purposes). +// +// The component is presentational only — no store reads, no async — so +// it stays trivial to test and renders identically under SSR / happy-dom. + +import type { CSSProperties } from "react"; +import type { InteractiveSessionRow } from "../../services/interactive"; + +/** Visual states surfaced by the badge. `null` means "no badge". */ +export type InteractiveBadgeState = "awaiting" | "detached" | "crashed"; + +export interface InteractiveBadgeProps { + state: InteractiveBadgeState; + /** Optional override for tests / non-English locales. Defaults to a + * built-in English label keyed off `state`. */ + label?: string; + /** Optional className applied alongside the state-specific class. */ + className?: string; +} + +const DEFAULT_LABELS: Record = { + awaiting: "Awaiting input", + detached: "Detached", + crashed: "Crashed", +}; + +/** + * Map a badge state to its presentation. Colors are picked from the + * existing theme token palette so the badge fits into every theme + * without inventing new tokens (which would fail `bun run lint:css`): + * + * - awaiting → `--badge-ask` (same hue family as the existing + * "Question requires attention" badge, since both signal "user + * input wanted"). + * - detached → `--text-dim` (muted; the session is alive but the + * user isn't currently looking at it). + * - crashed → `--status-stopped` (same red as the stopped-agent + * icon — a crashed interactive host is a hard failure). + */ +function styleForState(state: InteractiveBadgeState): CSSProperties { + switch (state) { + case "awaiting": + return { color: "var(--badge-ask)" }; + case "detached": + return { color: "var(--text-dim)" }; + case "crashed": + return { color: "var(--status-stopped)" }; + } +} + +const STATE_DATA_ATTR: Record = { + awaiting: "awaiting", + detached: "detached", + crashed: "crashed", +}; + +/** + * Renders a small inline text badge next to a workspace row in the + * sidebar. The badge carries its own `title` and `aria-label` so + * hover and screen-reader contexts both describe what the badge is + * signaling. + */ +export function InteractiveBadge({ + state, + label, + className, +}: InteractiveBadgeProps) { + const text = label ?? DEFAULT_LABELS[state]; + return ( + + {text} + + ); +} + +// --------------------------------------------------------------------------- +// State derivation +// --------------------------------------------------------------------------- + +/** + * Compute the single badge state to display for a workspace given the + * persisted interactive sessions for that workspace plus optional + * runtime signals. + * + * Precedence (highest first): + * 1. `crashed` — any session in DB state `"crashed"`. + * 2. `awaiting` — `awaitingFromAssembler` is `true`. The DB state + * column does not currently distinguish "awaiting" from "running" + * so the only authoritative awaiting signal in v1 is the live + * assembler state (G4). + * 3. `detached` — any session in DB state `"running"`. v1 treats + * every running session as "detached" for badge purposes; a + * future revision can refine this once we track which client is + * currently attached. + * 4. `null` — no badge. + * + * Sessions in DB states `"exited"` / `"unknown"` are ignored — a + * cleanly-exited session doesn't warrant a sidebar badge. + */ +export function computeInteractiveBadgeState( + sessions: readonly InteractiveSessionRow[] | undefined, + awaitingFromAssembler = false, +): InteractiveBadgeState | null { + if (!sessions || sessions.length === 0) { + // No persisted sessions. The live assembler signal still wins if + // present — an interactive session can have a `start` hook fire + // before its DB row lands on the next `listInteractive` refresh. + return awaitingFromAssembler ? "awaiting" : null; + } + let hasRunning = false; + for (const row of sessions) { + if (row.state === "crashed") return "crashed"; + if (row.state === "running") hasRunning = true; + } + if (awaitingFromAssembler) return "awaiting"; + if (hasRunning) return "detached"; + return null; +} diff --git a/src/ui/src/components/sidebar/Sidebar.module.css b/src/ui/src/components/sidebar/Sidebar.module.css index f262d7406..5e3a3c1a0 100644 --- a/src/ui/src/components/sidebar/Sidebar.module.css +++ b/src/ui/src/components/sidebar/Sidebar.module.css @@ -579,6 +579,24 @@ font-family: var(--font-mono); } +/* G7 — Interactive-session badge ("Awaiting input" / "Detached" / + "Crashed") rendered inline under the workspace branch. The colour + comes from the InteractiveBadge component's inline style (driven by + theme tokens); this class only owns layout + typography so the + badge sits flush with the existing sidebar row metrics. */ +.interactiveBadge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + margin-top: 2px; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + /* Running-commands summary row. Collapsed by default — clicking expands the list of individual commands underneath. Indented to nest visually under the workspace branch line. */ diff --git a/src/ui/src/components/sidebar/Sidebar.tsx b/src/ui/src/components/sidebar/Sidebar.tsx index 373d481cb..916757657 100644 --- a/src/ui/src/components/sidebar/Sidebar.tsx +++ b/src/ui/src/components/sidebar/Sidebar.tsx @@ -30,6 +30,7 @@ import { resolveScmPrIcon } from "../shared/workspaceStatusIcon"; import { RepoIcon } from "../shared/RepoIcon"; import { WorkspaceEnvSpinner } from "./WorkspaceEnvSpinner"; import { WorkspaceScmLinkIcon } from "./WorkspaceScmLinkIcon"; +import { InteractiveBadge, computeInteractiveBadgeState } from "./InteractiveBadge"; import { extractRemoteWorkspace } from "./remoteWorkspaceResponse"; import { HelpMenu } from "./HelpMenu"; import { UpdateBanner } from "../layout/UpdateBanner"; @@ -116,6 +117,7 @@ export const Sidebar = memo(function Sidebar() { const addToast = useAppStore((s) => s.addToast); const unreadCompletions = useAppStore((s) => s.unreadCompletions); const sessionsByWorkspace = useAppStore((s) => s.sessionsByWorkspace); + const interactiveSessionsByWorkspace = useAppStore((s) => s.interactiveSessionsByWorkspace); const setSessionsForWorkspace = useAppStore((s) => s.setSessionsForWorkspace); const scmSummary = useAppStore((s) => s.scmSummary); const workspaceEnvironment = useAppStore((s) => s.workspaceEnvironment); @@ -754,6 +756,17 @@ export const Sidebar = memo(function Sidebar() { )} {ws.branch_name} + {(() => { + const interactiveBadge = computeInteractiveBadgeState( + interactiveSessionsByWorkspace[ws.id], + ); + return interactiveBadge !== null ? ( + + ) : null; + })()} {(() => { if (!showSidebarRunningCommands) return null; const wsCommands = workspaceTerminalCommands[ws.id]; diff --git a/src/ui/src/stores/slices/interactiveSessionsSlice.ts b/src/ui/src/stores/slices/interactiveSessionsSlice.ts new file mode 100644 index 000000000..0802eda5e --- /dev/null +++ b/src/ui/src/stores/slices/interactiveSessionsSlice.ts @@ -0,0 +1,68 @@ +// G7 — Store slice tracking persisted interactive-session rows per +// workspace. The Sidebar consumes this via `computeInteractiveBadgeState` +// (see `components/sidebar/InteractiveBadge.tsx`) to render the +// Awaiting / Detached / Crashed badges next to workspace rows. +// +// The slice is intentionally minimal in G7: it just holds the rows +// keyed by workspace id and exposes a setter. A future change can +// add an `awaitingByWorkspace` map (live signal from the per-turn +// assembler in ChatPanel) to enrich the badge for the currently-open +// workspace; the slice's shape already accommodates that by keeping +// the raw rows separate from any derived signal. +// +// Wiring (G7 v1): +// - Slice is registered in `useAppStore.ts`. +// - Sidebar.tsx reads `interactiveSessionsByWorkspace` and renders +// the badge. +// - No automatic data loader is wired yet — population of the map +// (calling `listInteractive(workspaceId)` on workspace open / +// interactive event arrival) is deferred to a follow-up. Until +// that lands, the map is empty and no badge renders, which is +// the correct fallback ("rendering a workspace WITHOUT an +// interactive session shows no badge"). + +import type { StateCreator } from "zustand"; +import type { InteractiveSessionRow } from "../../services/interactive"; +import type { AppState } from "../useAppStore"; + +export interface InteractiveSessionsSlice { + /** Persisted `interactive_sessions` rows keyed by workspace id. + * Empty / missing entries mean "no interactive sessions for this + * workspace" — the sidebar selector treats undefined and `[]` + * identically. */ + interactiveSessionsByWorkspace: Record; + + /** Replace the row list for one workspace. Typically called after + * a `listInteractive(workspaceId)` resolves. */ + setInteractiveSessionsForWorkspace: ( + workspaceId: string, + sessions: InteractiveSessionRow[], + ) => void; + + /** Drop the row list for one workspace (e.g. when the workspace is + * deleted). */ + clearInteractiveSessionsForWorkspace: (workspaceId: string) => void; +} + +export const createInteractiveSessionsSlice: StateCreator< + AppState, + [], + [], + InteractiveSessionsSlice +> = (set) => ({ + interactiveSessionsByWorkspace: {}, + setInteractiveSessionsForWorkspace: (workspaceId, sessions) => + set((s) => ({ + interactiveSessionsByWorkspace: { + ...s.interactiveSessionsByWorkspace, + [workspaceId]: sessions, + }, + })), + clearInteractiveSessionsForWorkspace: (workspaceId) => + set((s) => { + if (!(workspaceId in s.interactiveSessionsByWorkspace)) return s; + const next = { ...s.interactiveSessionsByWorkspace }; + delete next[workspaceId]; + return { interactiveSessionsByWorkspace: next }; + }), +}); diff --git a/src/ui/src/stores/useAppStore.ts b/src/ui/src/stores/useAppStore.ts index 220ad9ea4..308fc2f8d 100644 --- a/src/ui/src/stores/useAppStore.ts +++ b/src/ui/src/stores/useAppStore.ts @@ -74,6 +74,10 @@ import { createWorkspaceClaudeFlagsSlice, type WorkspaceClaudeFlagsSlice, } from "./slices/workspaceClaudeFlagsSlice"; +import { + createInteractiveSessionsSlice, + type InteractiveSessionsSlice, +} from "./slices/interactiveSessionsSlice"; export type { AgentApproval, @@ -110,7 +114,8 @@ export type AppState = RepositoriesSlice & RemoteSlice & SystemSlice & TabOrderSlice & - WorkspaceClaudeFlagsSlice; + WorkspaceClaudeFlagsSlice & + InteractiveSessionsSlice; export const useAppStore = create()((...a) => ({ ...createRepositoriesSlice(...a), @@ -133,6 +138,7 @@ export const useAppStore = create()((...a) => ({ ...createSystemSlice(...a), ...createTabOrderSlice(...a), ...createWorkspaceClaudeFlagsSlice(...a), + ...createInteractiveSessionsSlice(...a), })); /** From 1d5ec6b891111f1b957a5e6e1e76efef48bff1bf Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 23:34:14 -0700 Subject: [PATCH 52/95] fix(ui): tighten InteractiveSessionRow.state to a typed union --- .../sidebar/InteractiveBadge.test.tsx | 11 +++- .../components/sidebar/InteractiveBadge.tsx | 55 +++++++++++++++---- src/ui/src/services/interactive.ts | 31 ++++++++++- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/ui/src/components/sidebar/InteractiveBadge.test.tsx b/src/ui/src/components/sidebar/InteractiveBadge.test.tsx index 10be1c9a7..ca91665ab 100644 --- a/src/ui/src/components/sidebar/InteractiveBadge.test.tsx +++ b/src/ui/src/components/sidebar/InteractiveBadge.test.tsx @@ -121,9 +121,9 @@ describe("computeInteractiveBadgeState", () => { expect(computeInteractiveBadgeState(rows, true)).toBe("awaiting"); }); - it("returns null when only exited / unknown sessions are persisted", () => { + it("returns null when only stopped / unknown sessions are persisted", () => { const rows = [ - makeRow({ sid: "a", state: "exited" }), + makeRow({ sid: "a", state: "stopped" }), makeRow({ sid: "b", state: "unknown" }), ]; expect(computeInteractiveBadgeState(rows)).toBeNull(); @@ -131,9 +131,14 @@ describe("computeInteractiveBadgeState", () => { it("ignores non-running, non-crashed states when computing 'detached'", () => { const rows = [ - makeRow({ sid: "a", state: "exited" }), + makeRow({ sid: "a", state: "stopped" }), makeRow({ sid: "b", state: "running" }), ]; expect(computeInteractiveBadgeState(rows)).toBe("detached"); }); + + it("treats DB state 'detached' the same as 'running' for badge purposes", () => { + const rows = [makeRow({ state: "detached" })]; + expect(computeInteractiveBadgeState(rows)).toBe("detached"); + }); }); diff --git a/src/ui/src/components/sidebar/InteractiveBadge.tsx b/src/ui/src/components/sidebar/InteractiveBadge.tsx index 878774336..057a7a1a3 100644 --- a/src/ui/src/components/sidebar/InteractiveBadge.tsx +++ b/src/ui/src/components/sidebar/InteractiveBadge.tsx @@ -32,7 +32,10 @@ // it stays trivial to test and renders identically under SSR / happy-dom. import type { CSSProperties } from "react"; -import type { InteractiveSessionRow } from "../../services/interactive"; +import type { + InteractiveSessionRow, + InteractiveSessionState, +} from "../../services/interactive"; /** Visual states surfaced by the badge. `null` means "no badge". */ export type InteractiveBadgeState = "awaiting" | "detached" | "crashed"; @@ -112,25 +115,52 @@ export function InteractiveBadge({ // State derivation // --------------------------------------------------------------------------- +/** + * Map a single DB-row state to the badge state it contributes (if + * any). Written as an exhaustive `switch` over `InteractiveSessionState` + * so TypeScript strict mode errors if a future state is added to the + * union without updating this selector. + * + * - `"running"` → `"detached"` (v1 treats any DB-`running` row as + * "detached"; the user-perceived attached state is + * owned by the active ChatPanel, not the row). + * - `"detached"` → `"detached"`. + * - `"crashed"` → `"crashed"`. + * - `"stopped"` → `null` (cleanly-stopped sessions don't warrant a + * sidebar badge). + * - `"unknown"` → `null` (defensive forward-compat fallback). + */ +function badgeStateForRow( + state: InteractiveSessionState, +): "awaiting" | "detached" | "crashed" | null { + switch (state) { + case "running": + case "detached": + return "detached"; + case "crashed": + return "crashed"; + case "stopped": + case "unknown": + return null; + } +} + /** * Compute the single badge state to display for a workspace given the * persisted interactive sessions for that workspace plus optional * runtime signals. * * Precedence (highest first): - * 1. `crashed` — any session in DB state `"crashed"`. + * 1. `crashed` — any session whose row contributes `"crashed"`. * 2. `awaiting` — `awaitingFromAssembler` is `true`. The DB state * column does not currently distinguish "awaiting" from "running" * so the only authoritative awaiting signal in v1 is the live * assembler state (G4). - * 3. `detached` — any session in DB state `"running"`. v1 treats - * every running session as "detached" for badge purposes; a - * future revision can refine this once we track which client is - * currently attached. + * 3. `detached` — any session whose row contributes `"detached"`. * 4. `null` — no badge. * - * Sessions in DB states `"exited"` / `"unknown"` are ignored — a - * cleanly-exited session doesn't warrant a sidebar badge. + * Sessions in DB states `"stopped"` / `"unknown"` contribute nothing — + * see {@link badgeStateForRow}. */ export function computeInteractiveBadgeState( sessions: readonly InteractiveSessionRow[] | undefined, @@ -142,12 +172,13 @@ export function computeInteractiveBadgeState( // before its DB row lands on the next `listInteractive` refresh. return awaitingFromAssembler ? "awaiting" : null; } - let hasRunning = false; + let hasDetached = false; for (const row of sessions) { - if (row.state === "crashed") return "crashed"; - if (row.state === "running") hasRunning = true; + const contribution = badgeStateForRow(row.state); + if (contribution === "crashed") return "crashed"; + if (contribution === "detached") hasDetached = true; } if (awaitingFromAssembler) return "awaiting"; - if (hasRunning) return "detached"; + if (hasDetached) return "detached"; return null; } diff --git a/src/ui/src/services/interactive.ts b/src/ui/src/services/interactive.ts index d4dde4d31..e3be21163 100644 --- a/src/ui/src/services/interactive.ts +++ b/src/ui/src/services/interactive.ts @@ -42,6 +42,33 @@ export interface StartInteractiveResult { hostKind: string; } +/** + * Canonical lifecycle states for a persisted `interactive_sessions` + * row. Mirrors the discrete `state` values written by the Rust CRUD in + * `src/db/interactive_sessions.rs` (A3 migration + A4 commands): + * + * - `"running"` — host has the session alive and Claudette is the + * active client. + * - `"detached"` — host has the session alive but Claudette is not + * currently attached to it. + * - `"stopped"` — session ended (graceful or forced teardown). + * - `"crashed"` — session terminated with a non-zero exit / host + * error; `crashReason` carries the diagnostic. + * - `"unknown"` — defensive forward-compat fallback so a future DB + * value doesn't crash the type checker; callers + * should treat this as "ignore for UI purposes". + * + * Typing this as a discriminated union (rather than plain `string`) + * keeps `computeInteractiveBadgeState` exhaustive — adding a new state + * here without updating the badge selector is a compile error. + */ +export type InteractiveSessionState = + | "running" + | "detached" + | "stopped" + | "crashed" + | "unknown"; + /** * Wire shape for a persisted `interactive_sessions` row, returned by * `interactive_list_for_workspace`. Mirrors Rust @@ -56,7 +83,7 @@ export interface InteractiveSessionRow { sid: string; workspaceId: string; hostKind: string; - state: string; + state: InteractiveSessionState; crashReason: string | null; createdAt: string; lastAttachedAt: string | null; @@ -154,7 +181,7 @@ export function captureScreen(sid: string): Promise { * Stop a running interactive session. `force=true` maps to a * `StopMode::Force` (SIGKILL on tmux, immediate teardown on sidecar); * the default is `StopMode::Graceful`. The DB row is updated to - * `state = "exited"` and the sid→workspace_id mapping is dropped. + * `state = "stopped"` and the sid→workspace_id mapping is dropped. */ export function stopInteractive(sid: string, force = false): Promise { return invoke("interactive_stop", { sid, force }); From 2194be53070ebba0b479653c98146044455ceee8 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Sat, 16 May 2026 23:41:50 -0700 Subject: [PATCH 53/95] feat(ui): copy tmux attach command for interactive sessions on Unix G8 surfaces a per-workspace "Copy tmux attach command" context-menu item that writes `tmux attach-session -t ` to the clipboard so the user can reattach from any terminal. The item is gated on the workspace having a live tmux interactive session (state running / detached); sidecar-only workspaces and workspaces without any interactive session don't render it. - `buildWorkspaceContextMenuItems` learns an optional `tmuxAttachSid` on the target and a matching `copyTmuxAttachCommand` callback. The item only renders when both are set, keeping the menu surface identical on Windows and on Unix-without-tmux. - Sidebar derives the sid from `interactiveSessionsByWorkspace` via a new `pickTmuxAttachSid` helper, calls `navigator.clipboard.writeText` per the task spec, and shows a "Copied: " toast. - New tests cover both visibility gating and the clipboard call shape. - Locale strings added for en / es / ja / pt-BR / zh-CN. --- src/ui/src/components/sidebar/Sidebar.tsx | 56 +++++++- .../sidebar/workspaceContextMenu.test.tsx | 131 ++++++++++++++++++ .../sidebar/workspaceContextMenu.tsx | 33 +++++ src/ui/src/locales/en/sidebar.json | 2 + src/ui/src/locales/es/sidebar.json | 2 + src/ui/src/locales/ja/sidebar.json | 2 + src/ui/src/locales/pt-BR/sidebar.json | 2 + src/ui/src/locales/zh-CN/sidebar.json | 2 + 8 files changed, 229 insertions(+), 1 deletion(-) diff --git a/src/ui/src/components/sidebar/Sidebar.tsx b/src/ui/src/components/sidebar/Sidebar.tsx index 916757657..bd2460900 100644 --- a/src/ui/src/components/sidebar/Sidebar.tsx +++ b/src/ui/src/components/sidebar/Sidebar.tsx @@ -52,11 +52,13 @@ import { STATUS_BUCKET_ORDER, } from "../../utils/sidebarJumpTargets"; import { + buildTmuxAttachCommand, buildWorkspaceContextMenuItems, type WorkspaceContextMenuLabels, } from "./workspaceContextMenu"; import { JumpShortcutBadge } from "./JumpShortcutBadge"; import type { ChatSession } from "../../types"; +import type { InteractiveSessionRow } from "../../services/interactive"; import styles from "./Sidebar.module.css"; function workspaceContextMenuLabels(t: TFunction<"sidebar">): WorkspaceContextMenuLabels { @@ -67,12 +69,39 @@ function workspaceContextMenuLabels(t: TFunction<"sidebar">): WorkspaceContextMe openInTerminal: t("context_open_in_terminal"), copyWorkingDirectory: t("context_copy_working_directory"), copyClaudeSessionId: t("context_copy_claude_session_id"), + copyTmuxAttachCommand: t("context_copy_tmux_attach_command"), archiveWorkspace: t("archive_workspace"), restoreWorkspace: t("restore_workspace"), deleteWorkspace: t("delete_workspace"), }; } +/** + * G8: pick a tmux interactive session sid suitable for the "Copy tmux + * attach command" menu item. Returns the sid of the first session whose + * host is tmux AND whose state is `running` or `detached` (i.e. the + * underlying tmux session is alive and attachable). Returns null when: + * - the workspace has no persisted interactive sessions, OR + * - every interactive session is on the sidecar host (Windows path or + * a Unix box without tmux), OR + * - every tmux session is in a terminal state (`stopped` / `crashed`). + * + * The selector deliberately ignores `unknown` so a forward-compat row + * doesn't accidentally enable the menu item — we only enable when we're + * sure tmux can attach. + */ +function pickTmuxAttachSid( + sessions: readonly InteractiveSessionRow[] | undefined, +): string | null { + if (!sessions || sessions.length === 0) return null; + for (const row of sessions) { + if (row.hostKind !== "tmux") continue; + if (row.state !== "running" && row.state !== "detached") continue; + return row.sid; + } + return null; +} + function pickClaudeSessionId( sessions: readonly ChatSession[], selectedSessionId: string | undefined, @@ -440,8 +469,16 @@ export const Sidebar = memo(function Sidebar() { if (!workspaceContextMenu) return []; const ws = workspaces.find((w) => w.id === workspaceContextMenu.workspaceId); if (!ws) return []; + const tmuxAttachSid = pickTmuxAttachSid( + interactiveSessionsByWorkspace[ws.id], + ); return buildWorkspaceContextMenuItems( - { status: ws.status, worktreePath: ws.worktree_path, remote: false }, + { + status: ws.status, + worktreePath: ws.worktree_path, + remote: false, + tmuxAttachSid, + }, workspaceContextMenuLabels(t), { rename: () => { @@ -484,6 +521,22 @@ export const Sidebar = memo(function Sidebar() { await clipboardWriteText(claudeSessionId); addToast(t("context_copied_claude_session_id")); }, + // G8: only wire the copy callback when the selector picked a + // live tmux sid. `buildWorkspaceContextMenuItems` hides the + // item entirely when either `tmuxAttachSid` or this callback + // is missing, so both gates have to agree before the item + // renders. + copyTmuxAttachCommand: tmuxAttachSid + ? async () => { + const cmd = buildTmuxAttachCommand(tmuxAttachSid); + // Task spec calls navigator.clipboard.writeText + // directly (rather than the Tauri plugin used by the + // sibling copy actions) so the action keeps working in + // the webview without an additional capability grant. + await navigator.clipboard.writeText(cmd); + addToast(t("context_copied_tmux_attach_command", { cmd })); + } + : undefined, archive: ws.status === "Active" ? () => handleArchive(ws.id) : undefined, restore: ws.status === "Archived" ? () => handleRestore(ws.id) : undefined, delete: @@ -500,6 +553,7 @@ export const Sidebar = memo(function Sidebar() { addToast, handleArchive, handleRestore, + interactiveSessionsByWorkspace, markWorkspaceAsUnread, openModal, setSessionsForWorkspace, diff --git a/src/ui/src/components/sidebar/workspaceContextMenu.test.tsx b/src/ui/src/components/sidebar/workspaceContextMenu.test.tsx index a2daefef1..8edd8a55f 100644 --- a/src/ui/src/components/sidebar/workspaceContextMenu.test.tsx +++ b/src/ui/src/components/sidebar/workspaceContextMenu.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + buildTmuxAttachCommand, buildWorkspaceContextMenuItems, type WorkspaceContextMenuLabels, } from "./workspaceContextMenu"; @@ -12,6 +13,7 @@ const labels: WorkspaceContextMenuLabels = { openInTerminal: "Open in Terminal", copyWorkingDirectory: "Copy Working Directory", copyClaudeSessionId: "Copy Claude Session ID", + copyTmuxAttachCommand: "Copy tmux attach command", archiveWorkspace: "Archive", restoreWorkspace: "Restore", deleteWorkspace: "Delete", @@ -117,4 +119,133 @@ describe("buildWorkspaceContextMenuItems", () => { expect(itemLabels(items)).toEqual(["Mark as Unread", "Archive"]); }); + + it("shows tmux attach menu item only when hostKind is tmux", () => { + const noop = vi.fn(); + // Tmux session present → item appears. + const withTmux = buildWorkspaceContextMenuItems( + { + status: "Active", + worktreePath: "/tmp/workspace", + remote: false, + tmuxAttachSid: "claude-abc123", + }, + labels, + { + rename: noop, + markAsUnread: noop, + openInFileManager: noop, + openInTerminal: noop, + copyWorkingDirectory: noop, + copyClaudeSessionId: noop, + copyTmuxAttachCommand: noop, + archive: noop, + }, + ); + expect(itemLabels(withTmux)).toContain("Copy tmux attach command"); + + // No interactive sessions → item hidden. + const noInteractive = buildWorkspaceContextMenuItems( + { + status: "Active", + worktreePath: "/tmp/workspace", + remote: false, + tmuxAttachSid: null, + }, + labels, + { + rename: noop, + markAsUnread: noop, + openInFileManager: noop, + openInTerminal: noop, + copyWorkingDirectory: noop, + copyClaudeSessionId: noop, + archive: noop, + }, + ); + expect(itemLabels(noInteractive)).not.toContain("Copy tmux attach command"); + + // Sidecar session (no tmux sid) → item hidden. + const sidecarOnly = buildWorkspaceContextMenuItems( + { + status: "Active", + worktreePath: "/tmp/workspace", + remote: false, + // The sidebar selector returns null when the only host is sidecar; + // we model that here by simply not setting tmuxAttachSid. + }, + labels, + { + rename: noop, + markAsUnread: noop, + openInFileManager: noop, + openInTerminal: noop, + copyWorkingDirectory: noop, + copyClaudeSessionId: noop, + archive: noop, + }, + ); + expect(itemLabels(sidecarOnly)).not.toContain("Copy tmux attach command"); + }); + + it("copies the tmux attach command to clipboard", async () => { + // Spy on navigator.clipboard.writeText. jsdom may or may not provide + // the clipboard surface, so install a writable stub for the duration + // of the test. + const writeText = vi.fn().mockResolvedValue(undefined); + const originalClipboard = ( + navigator as unknown as { clipboard?: Clipboard } + ).clipboard; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + try { + const sid = "claude-abc123"; + const items = buildWorkspaceContextMenuItems( + { + status: "Active", + worktreePath: "/tmp/workspace", + remote: false, + tmuxAttachSid: sid, + }, + labels, + { + markAsUnread: vi.fn(), + // Mirror the Sidebar wiring: the callback invokes + // navigator.clipboard.writeText with the tmux attach command. + copyTmuxAttachCommand: async () => { + await navigator.clipboard.writeText(buildTmuxAttachCommand(sid)); + }, + }, + ); + + const tmuxItem = actionable(items).find( + (item) => item.label === "Copy tmux attach command", + ); + expect(tmuxItem).toBeDefined(); + expect(tmuxItem?.disabled).toBeFalsy(); + + // Invoke the menu item's onSelect — the same path the ContextMenu + // component runs on click. + await tmuxItem?.onSelect?.(); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(`tmux attach-session -t ${sid}`); + } finally { + if (originalClipboard === undefined) { + // jsdom default: remove the property we installed. + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined, + }); + } else { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + } + } + }); }); diff --git a/src/ui/src/components/sidebar/workspaceContextMenu.tsx b/src/ui/src/components/sidebar/workspaceContextMenu.tsx index 0119ee466..15400b764 100644 --- a/src/ui/src/components/sidebar/workspaceContextMenu.tsx +++ b/src/ui/src/components/sidebar/workspaceContextMenu.tsx @@ -18,6 +18,7 @@ export interface WorkspaceContextMenuLabels { openInTerminal: string; copyWorkingDirectory: string; copyClaudeSessionId: string; + copyTmuxAttachCommand: string; archiveWorkspace: string; restoreWorkspace: string; deleteWorkspace: string; @@ -27,6 +28,15 @@ export interface WorkspaceContextMenuTarget { status: WorkspaceStatus; worktreePath: string | null; remote: boolean; + /** + * G8: the sid of an attachable interactive session whose host is + * tmux (Unix-only). When non-null, the menu surfaces a "Copy tmux + * attach command" item that copies `tmux attach-session -t `. + * Null / undefined hides the item entirely — there's no + * Windows-friendly equivalent for the sidecar host, and tmux is the + * canonical gate (a tmux session implies the user is on Unix). + */ + tmuxAttachSid?: string | null; } export interface WorkspaceContextMenuCallbacks { @@ -36,11 +46,25 @@ export interface WorkspaceContextMenuCallbacks { openInTerminal?: () => void | Promise; copyWorkingDirectory?: () => void | Promise; copyClaudeSessionId?: () => void | Promise; + /** + * G8: invoked when the user selects "Copy tmux attach command". + * Only wired by the caller when `target.tmuxAttachSid` is non-null. + */ + copyTmuxAttachCommand?: () => void | Promise; archive?: () => void | Promise; restore?: () => void | Promise; delete?: () => void | Promise; } +/** + * Build the `tmux attach-session -t ` command string copied by the + * "Copy tmux attach command" menu action. Exported so tests and the + * Sidebar wiring share one canonical formatter. + */ +export function buildTmuxAttachCommand(sid: string): string { + return `tmux attach-session -t ${sid}`; +} + export function buildWorkspaceContextMenuItems( target: WorkspaceContextMenuTarget, labels: WorkspaceContextMenuLabels, @@ -103,6 +127,15 @@ export function buildWorkspaceContextMenuItems( onSelect: callbacks.copyClaudeSessionId ?? (() => {}), disabled: !callbacks.copyClaudeSessionId, }, + ...(target.tmuxAttachSid && callbacks.copyTmuxAttachCommand + ? [ + { + label: labels.copyTmuxAttachCommand, + icon:
- {/* TODO(G2 follow-up): card is currently display-only. Actual runtime - selection happens in RuntimeSelector. When that flow is wired to - accept claude_interactive (via Fix 1 to effectiveHarness), revisit - whether this card should also offer a direct "Use this runtime" - affordance. */} + {/* Display-only summary card. Actual per-backend runtime selection + happens in `RuntimeSelector` further down — when the experimental + flag is on, the Anthropic / CustomAnthropic / CodexSubscription + cards offer `Claude (Interactive)` as a runtime option there. */}
({ invoke: vi.fn() })); import { type AgentBackendConfig, + availableHarnessesForKind, effectiveHarness, } from "./agentBackends"; @@ -107,3 +108,58 @@ describe("effectiveHarness", () => { ).toBe("claude_code"); }); }); + +describe("availableHarnessesForKind", () => { + it("omits claude_interactive from every kind when the flag is OFF (default)", () => { + // Back-compat baseline: callers without the option get the static + // matrix, identical to the pre-FB-1 behaviour. + const kinds = [ + "anthropic", + "custom_anthropic", + "codex_subscription", + "ollama", + "lm_studio", + "openai_api", + "custom_openai", + "codex_native", + "pi_sdk", + ] as const; + for (const kind of kinds) { + const harnesses = availableHarnessesForKind(kind); + expect(harnesses).not.toContain("claude_interactive"); + // And explicit `false` matches the default-undefined case. + expect( + availableHarnessesForKind(kind, { claudeInteractiveEnabled: false }), + ).toEqual(harnesses); + } + }); + + it("appends claude_interactive for Anthropic / CustomAnthropic / CodexSubscription when the flag is ON", () => { + for (const kind of ["anthropic", "custom_anthropic", "codex_subscription"] as const) { + expect( + availableHarnessesForKind(kind, { claudeInteractiveEnabled: true }), + ).toEqual(["claude_code", "claude_interactive"]); + } + }); + + it("does not append claude_interactive for non-Claude-flavored kinds even when the flag is ON", () => { + // ClaudeInteractive is a Claude-runtime variant — Pi, Ollama, LM + // Studio, OpenAI-flavored, and Codex Native must never surface it. + const kinds = [ + "ollama", + "lm_studio", + "openai_api", + "custom_openai", + "codex_native", + "pi_sdk", + ] as const; + for (const kind of kinds) { + const withFlag = availableHarnessesForKind(kind, { + claudeInteractiveEnabled: true, + }); + const withoutFlag = availableHarnessesForKind(kind); + expect(withFlag).not.toContain("claude_interactive"); + expect(withFlag).toEqual(withoutFlag); + } + }); +}); diff --git a/src/ui/src/services/tauri/agentBackends.ts b/src/ui/src/services/tauri/agentBackends.ts index ba07e82e9..605d5fc27 100644 --- a/src/ui/src/services/tauri/agentBackends.ts +++ b/src/ui/src/services/tauri/agentBackends.ts @@ -166,29 +166,53 @@ export function defaultHarnessForKind( } /** - * Mirror of `AgentBackendKind::available_harnesses`. The first entry is - * the default. Pinning a value outside this list is rejected server-side - * by `set_agent_backend_runtime_harness`. + * Mirror of `AgentBackendKind::available_harnesses` / + * `available_harnesses_with_interactive`. The first entry is the + * default. Pinning a value outside this list is rejected server-side by + * `set_agent_backend_runtime_harness`. + * + * `"claude_interactive"` is intentionally **not** in the static matrix: + * it's gated by the `claudeInteractiveEnabled` experimental flag, not + * the per-kind allow-list. Callers that have the flag value available + * (the Settings runtime picker, anything driving persistence) should + * pass it via `options.claudeInteractiveEnabled` so the Claude-flavored + * kinds (Anthropic, CustomAnthropic, CodexSubscription) gain + * `"claude_interactive"` as a second option. Other call sites — the + * Pi-disabled downgrade in `resolveSessionHarness`, the gateway-hash + * key — want the matrix shape and should call without the option (the + * flag defaults to `false`). */ export function availableHarnessesForKind( kind: AgentBackendKind, + options?: { claudeInteractiveEnabled?: boolean }, ): AgentBackendRuntimeHarness[] { - switch (kind) { - case "anthropic": - case "custom_anthropic": - case "codex_subscription": - return ["claude_code"]; - case "ollama": - case "lm_studio": - return ["pi_sdk", "claude_code"]; - case "openai_api": - case "custom_openai": - return ["claude_code", "pi_sdk"]; - case "codex_native": - return ["codex_app_server", "pi_sdk"]; - case "pi_sdk": - return ["pi_sdk"]; + const base: AgentBackendRuntimeHarness[] = (() => { + switch (kind) { + case "anthropic": + case "custom_anthropic": + case "codex_subscription": + return ["claude_code"]; + case "ollama": + case "lm_studio": + return ["pi_sdk", "claude_code"]; + case "openai_api": + case "custom_openai": + return ["claude_code", "pi_sdk"]; + case "codex_native": + return ["codex_app_server", "pi_sdk"]; + case "pi_sdk": + return ["pi_sdk"]; + } + })(); + if ( + options?.claudeInteractiveEnabled === true && + (kind === "anthropic" || + kind === "custom_anthropic" || + kind === "codex_subscription") + ) { + base.push("claude_interactive"); } + return base; } /** Effective harness for a config: persisted override when allowed, From 7203c0d72d6728fe3bc4a4cb4cd5288c9a8b62ae Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Fri, 22 May 2026 14:37:01 -0700 Subject: [PATCH 93/95] fix(ui): mock effectiveHarness with flag guard; fix doc comment typos --- .../settings/RuntimeSelector.test.tsx | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/ui/src/components/settings/RuntimeSelector.test.tsx b/src/ui/src/components/settings/RuntimeSelector.test.tsx index 576e13ab7..a34bec688 100644 --- a/src/ui/src/components/settings/RuntimeSelector.test.tsx +++ b/src/ui/src/components/settings/RuntimeSelector.test.tsx @@ -77,8 +77,31 @@ const serviceMocks = vi.hoisted(() => ({ } return base; }, - effectiveHarness: (backend: AgentBackendConfig) => { - if (backend.runtime_harness) return backend.runtime_harness; + effectiveHarness: ( + backend: AgentBackendConfig, + options?: { claudeInteractiveEnabled?: boolean }, + ) => { + const override = backend.runtime_harness ?? undefined; + // Mirror the real `effectiveHarness` guard: `"claude_interactive"` + // is only honored when the experimental flag is on (it's + // intentionally absent from the per-kind matrix). Other persisted + // overrides are honored when they appear in the kind's allow-list; + // otherwise we fall through to the kind's default. + if ( + override === "claude_interactive" && + options?.claudeInteractiveEnabled === true + ) { + return override; + } + if ( + override && + override !== "claude_interactive" && + serviceMocks + .availableHarnessesForKind(backend.kind, options) + .includes(override) + ) { + return override; + } return serviceMocks.defaultHarnessForKind(backend.kind); }, })); @@ -345,4 +368,35 @@ describe("RuntimeSelector", () => { appStore.claudeInteractiveEnabled = false; } }); + + it("falls back to the kind's default when runtime_harness is claude_interactive but the flag is OFF", () => { + // Regression: the previous mock unconditionally returned the + // persisted override, including `"claude_interactive"`. The real + // `effectiveHarness` honors `"claude_interactive"` only when the + // experimental flag is on; otherwise it falls through to the kind's + // default. The selector won't render for Anthropic (single available + // harness with the flag off), so use Ollama where the kind has a + // multi-entry matrix. Its default harness is `pi_sdk`. + appStore.claudeInteractiveEnabled = false; + appStore.agentBackends = [ + makeBackend({ id: "pi", kind: "pi_sdk", enabled: true }), + ]; + const container = mount( + {}} + />, + ); + const select = container.querySelector("select") as HTMLSelectElement; + expect(select).not.toBeNull(); + // Flag is OFF → the persisted `claude_interactive` override is + // dropped, and the effective harness is the kind's default (pi_sdk). + expect(select.value).toBe("pi_sdk"); + // `claude_interactive` is NOT in the option list either (matrix gate). + const values = Array.from(select.options).map((o) => o.value); + expect(values).not.toContain("claude_interactive"); + }); }); From ecc1baf0c67fd28e48aeca9a0c0fb2de0a5cdab8 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Fri, 22 May 2026 14:39:54 -0700 Subject: [PATCH 94/95] chore: document stub-tui fixture; add src-session-host to version-sync gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify in CLAUDE.md's project-structure list that tests/fixtures/stub-tui is relied on by BOTH the claudette lib's interactive tests AND the claudette-session-host integration tests (attach_stream / ensure_session / handshake). Note on the version-sync gate: scripts/check-cargo-version-sync.sh already discovers workspace members dynamically via `cargo metadata`, so no change is needed there — src-session-host is included automatically and the check passes at 0.25.0 across all 7 packages. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index fc9d81423..25507e8ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,13 @@ src-session-host/ — `claudette-session-host` sidecar binary. Owns intera Windows, opt-in on Unix. Bundled as a Tauri `externalBin`. tests/ — workspace-level Rust integration tests (e.g. `grants_enforcement.rs` covering the community-plugin granted_capabilities flow). + `tests/fixtures/stub-tui/` is a tiny workspace member that builds a + standalone TUI binary used as a fake `claude` PTY in tests; relied on + by both the `claudette` lib's interactive tests (e.g. + `interactive_lifecycle`, `commands/interactive`) and the + `claudette-session-host` integration tests under + `src-session-host/tests/` (`attach_stream.rs`, `ensure_session.rs`, + `handshake.rs`). ``` ### Plugin system From e8efaa932e4ef735127cc3b27885973477a26705 Mon Sep 17 00:00:00 2001 From: Kevin Poorman Date: Fri, 22 May 2026 14:44:28 -0700 Subject: [PATCH 95/95] test(session-host): gate stub-tui READY behind STUB_TUI_DELAY_MS to deflake lag test The `attach_lagged_subscriber_stream_ends` test flaked once on Linux CI under `cargo llvm-cov`, running its full 10s `drain_until_contains` budget without ever observing `READY`. Root cause: a tokio broadcast join race. The stub-tui prints `READY` on startup before any attach client has subscribed to the per-session `broadcast::Sender`. Under llvm-cov instrumentation the PTY reader can broadcast `READY` to zero subscribers, the message is dropped, and the slow drainer that subscribes moments later never sees it. Fix: add a `STUB_TUI_DELAY_MS` env var to the stub-tui that sleeps before the initial `println!("READY")`. Only the lag test sets it (to 200 ms), so `attach_streams_echoed_output` and other tests keep their fast-start path. The 10s timeout widening from 0bffca1c stays as belt-and-suspenders. Verified 10/10 sequential runs of the lag test pass; full session-host suite green; clippy + fmt clean; interactive coverage gate still passes at 85.75% / 85%. Co-Authored-By: Claude Opus 4.7 --- src-session-host/tests/attach_stream.rs | 7 ++++++- tests/fixtures/stub-tui/src/main.rs | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src-session-host/tests/attach_stream.rs b/src-session-host/tests/attach_stream.rs index 1b947b8c5..675a6cea0 100644 --- a/src-session-host/tests/attach_stream.rs +++ b/src-session-host/tests/attach_stream.rs @@ -163,6 +163,11 @@ async fn attach_lagged_subscriber_stream_ends() { handshake(&mut ctrl_r, &mut ctrl_w).await; let sid = "claudette-attach-lagged".to_string(); + // Delay the stub-tui's `READY` print so attach connections have time to + // subscribe to the per-session broadcast channel before the line is sent. + // Without this, under `cargo llvm-cov` instrumentation the PTY reader can + // broadcast `READY` to zero subscribers, the message is dropped, and + // `drain_until_contains("READY", …)` runs to its full 10s budget. send_req( &mut ctrl_w, &Request::EnsureSession { @@ -173,7 +178,7 @@ async fn attach_lagged_subscriber_stream_ends() { cols: 80, claude_binary: stub.to_string_lossy().into(), claude_args: vec![], - env: vec![], + env: vec![("STUB_TUI_DELAY_MS".into(), "200".into())], claude_config_dir: std::env::temp_dir().to_string_lossy().into(), }, }, diff --git a/tests/fixtures/stub-tui/src/main.rs b/tests/fixtures/stub-tui/src/main.rs index 0d88c93c2..3aedc292c 100644 --- a/tests/fixtures/stub-tui/src/main.rs +++ b/tests/fixtures/stub-tui/src/main.rs @@ -6,11 +6,23 @@ //! - If `STUB_TUI_FAKE_AWAITING_AFTER` is set to a positive integer N, after //! echoing N lines we exit 0 (simulating `Stop` hook) without further output. //! - If `STUB_TUI_CRASH_AFTER` is set, panic after that many lines. +//! - If `STUB_TUI_DELAY_MS` is set to a non-negative integer, sleep that many +//! milliseconds before printing `READY`. Used by the attach lag-broadcast +//! test to deflake a join race: under `cargo llvm-cov` the PTY reader can +//! broadcast `READY` before any attach client has subscribed to the +//! per-session `broadcast::Sender`, so subscribers miss the line entirely. +//! Delaying startup output gives attach connections time to subscribe. //! - Line `quit\n` exits 0 immediately. use std::io::{BufRead, Write}; fn main() { + if let Ok(s) = std::env::var("STUB_TUI_DELAY_MS") + && let Ok(ms) = s.parse::() + { + std::thread::sleep(std::time::Duration::from_millis(ms)); + } + let mut stdout = std::io::stdout().lock(); writeln!(stdout, "READY").unwrap(); stdout.flush().unwrap();