diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f4022aed3..58fbf5d85 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,9 +1,11 @@ # Claudette Repository Instructions -Claudette is a cross-platform desktop orchestrator for parallel Claude Code agents. It uses a Tauri 2 Rust backend, a React/TypeScript frontend, SQLite via rusqlite, Tokio process management, git worktrees, xterm.js terminals, Lua plugins, and a local CLI that talks to the running GUI over IPC. +Claudette is a cross-platform desktop orchestrator for parallel Claude Code agents. It uses a Tauri 2 Rust backend, a React/TypeScript frontend, SQLite via rusqlite, Tokio process management, git worktrees, xterm.js terminals, Lua plugins, and a local CLI that talks to the running GUI over IPC. A fifth workspace crate, `claudette-session-host` (`src-session-host/`), is a bundled sidecar that owns interactive Claude PTYs via `portable-pty` — required on Windows and an opt-in Unix fallback when tmux is unavailable. Follow the existing architecture. Core behavior belongs in the `claudette` crate under `src/`. `src-tauri/src/commands/` should stay thin Tauri wrappers. Shared data types belong in `src/model/` and must derive `Serialize`. Frontend state lives in Zustand slices under `src/ui/src/stores/slices/`, not ad hoc globals. +The chat-send resolver dispatches on a per-backend `runtime_harness` (`AgentBackendConfig::effective_harness` in `src/agent_backend.rs`), not on `AgentBackendKind` directly. A second Anthropic harness, `AgentHarnessKind::ClaudeInteractive` (`src/agent/claude_interactive.rs`), runs the real `claude` TUI inside a long-lived PTY — gated behind the `claudeInteractiveEnabled` experimental flag and surfaced as the "Claude (Interactive)" card in Settings > Models > Runtime. Two hosts back it: `TmuxHost` (Unix, when `tmux >= 3.4` is available and the flag is on) and `SidecarHost` (Windows + opt-in Unix), both implementing `InteractiveHost` in `src/agent/interactive_host/`. The framed JSON wire protocol with the sidecar lives in `src/agent/interactive_protocol.rs`. Turn boundaries flow over Claude Code hooks: the `claudette chat hook` CLI subcommand sends a `chat_hook` IPC request that `AppState::dispatch_interactive_hook` routes into per-session channels, which the frontend assembler (`useInteractiveTurnAssembler`) turns into per-turn xterm.js views (`InteractiveTurnView` and friends in `src/ui/src/components/chat/`, with `services/interactive.ts` as the Tauri bridge). The interactive surface lives in sibling files next to `ChatPanel.tsx` (a god file) — keep new runtime-specific UI in dedicated components, not inside ChatPanel. **Known limitation:** `SidecarHost` does not yet recover from a dropped sidecar connection — the cached `ConnHandle` in `OnceCell` is never reset, so if `claudette-session-host` exits (e.g., its 600s idle timer fires) while Claudette is still running, subsequent `interactive_*` commands fail with "conn closed" errors until Claudette is restarted. Tracked as follow-up work. + Treat regressions as the main risk. Before changing behavior, identify the current contract: persisted DB fields, Tauri command payloads, CLI output, plugin manifests, settings keys, localized strings, terminal/session semantics, worktree behavior, and visible UI workflows. Do not remove, rename, or silently reinterpret any of these unless the task explicitly asks for that change. If a behavior change is intentional, call it out plainly in the PR summary or review response and add/update tests that pin the new behavior. If the change was incidental, preserve compatibility instead. @@ -14,7 +16,7 @@ Control god files. Do not make already-large files the default destination for n When touching a god file, keep the diff surgical or extract cohesive behavior first. New code should reduce or isolate complexity, not add another unrelated responsibility. -Use the repo's tools. Rust CI expects `cargo fmt --all --check`, `cargo clippy -p claudette -p claudette-server -p claudette-cli --all-targets --all-features` with `RUSTFLAGS=-Dwarnings`, tests for the same three crates on Linux (run via `cargo llvm-cov -p claudette -p claudette-server -p claudette-cli --all-features` for coverage), and `scripts/stage-cli-sidecar.sh --profile debug` followed by `cargo test -p claudette-tauri --no-default-features --features devtools,server,voice,alternative-backends,pi-sdk --no-run` with Linux Tauri system libraries installed (`--no-run` compiles the binary crate's test targets so a broken `claudette-tauri` test fixture is caught — the coverage `test` job only builds `claudette` / `-server` / `-cli`). The Tauri check should stay cache-friendly: use the shared Rust cache and Bun package cache for Pi harness sidecar staging instead of release-building extra artifacts. A separate macOS job also runs `cargo clippy -p claudette-mobile --target aarch64-apple-darwin --all-targets --locked` and `cargo test -p claudette-mobile --locked`. Frontend CI expects `cd src/ui && bun install --frozen-lockfile`, `bunx tsc --noEmit`, `bun run lint:css`, `bun run build`, and `bun run test` (ESLint via `bun run lint` is available locally but not run in CI). +Use the repo's tools. Rust CI expects `cargo fmt --all --check`, `cargo clippy -p claudette -p claudette-server -p claudette-cli --all-targets --all-features` with `RUSTFLAGS=-Dwarnings`, tests for the same three crates on Linux (run via `cargo llvm-cov -p claudette -p claudette-server -p claudette-cli --all-features` for coverage), and `scripts/stage-cli-sidecar.sh --profile debug` followed by `cargo test -p claudette-tauri --no-default-features --features devtools,server,voice,alternative-backends,pi-sdk --no-run` with Linux Tauri system libraries installed (`--no-run` compiles the binary crate's test targets so a broken `claudette-tauri` test fixture is caught — the coverage `test` job only builds `claudette` / `-server` / `-cli`). The Tauri check should stay cache-friendly: use the shared Rust cache and Bun package cache for Pi harness sidecar staging instead of release-building extra artifacts. A separate macOS job also runs `cargo clippy -p claudette-mobile --target aarch64-apple-darwin --all-targets --locked` and `cargo test -p claudette-mobile --locked`. CI also runs the Claude Interactive patch-coverage gate via `scripts/coverage-interactive.sh` + `scripts/check-coverage-interactive.sh` — informational until Task G1 flips it blocking at >=85% line coverage. Frontend CI expects `cd src/ui && bun install --frozen-lockfile`, `bunx tsc --noEmit`, `bun run lint:css`, `bun run build`, and `bun run test` (ESLint via `bun run lint` is available locally but not run in CI). Always run `cd src/ui && bunx tsc -b` after TypeScript changes. Vitest uses esbuild and does not type-check the project. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 160055729..7d50f1bc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,16 @@ jobs: slug: utensils/claudette files: lcov.info fail_ci_if_error: false + # Claude Interactive patch-coverage gate (blocking — Task G1). + # See scripts/check-coverage-interactive.sh and CLAUDE.md. Files + # excluded from the gate (e.g. tmux.rs, sidecar.rs) are documented + # inline in the check script with their structural rationale. + - name: Run llvm-cov (interactive patch JSON) + run: ./scripts/coverage-interactive.sh + - name: Check interactive patch coverage + env: + COVERAGE_GATE_BLOCKING: "1" + run: ./scripts/check-coverage-interactive.sh frontend: name: Frontend @@ -165,6 +175,10 @@ jobs: - run: bun run lint:css - run: bun run build - run: bun run test + # Claude Interactive patch-coverage gate for the frontend (blocking — + # Task G1). Per-file thresholds live in `vitest.config.ts` and vitest + # exits non-zero if any of them regresses. + - run: bun run test:coverage:interactive frontend-smoke: name: Frontend Bundle Smoke diff --git a/CLAUDE.md b/CLAUDE.md index 342c103b7..25507e8ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ cargo clippy -p claudette -p claudette-server -p claudette-cli --all-targets --a cargo clippy -p claudette-mobile --target aarch64-apple-darwin --all-targets --locked # Mobile lint (CI command — macOS only) cargo test -p claudette-mobile --locked # Mobile tests (CI command — macOS only) cargo fmt --all --check # Check formatting +./scripts/coverage-interactive.sh && ./scripts/check-coverage-interactive.sh # Claude Interactive patch-coverage gate (informational; blocking gate lands in Task G1) # Frontend (React/TypeScript) cd src/ui && bun install # Install frontend dependencies @@ -81,12 +82,13 @@ CI also enforces `bun install --frozen-lockfile` — do not modify `bun.lock` wi - **Data persistence**: SQLite via rusqlite (bundled) - **Git operations**: Shelling out to `git` via `tokio::process::Command` for worktree ops - **Agent integration**: Claude CLI subprocess with JSON streaming, bridged to frontend via Tauri events. The chat-send resolver dispatches on a per-backend `runtime_harness` (`AgentBackendConfig::effective_harness` in `src/agent_backend.rs`), not on `AgentBackendKind` directly — `kind` only declares which harnesses are valid; the user picks the active one in Settings > Models > Runtime. Ollama / LM Studio default to Pi, OpenAI cards to the Claude CLI gateway, Codex Native to the Codex app-server. Anthropic / Custom Anthropic / Codex Subscription are locked to Claude CLI so subscription OAuth tokens never reach Pi. +- **Interactive Claude (experimental)**: a second Anthropic harness, `AgentHarnessKind::ClaudeInteractive` (`src/agent/claude_interactive.rs`), runs the real `claude` TUI inside a long-lived PTY instead of the JSON-streaming subprocess. It is gated behind the `claudeInteractiveEnabled` experimental flag and surfaced as a "Claude (Interactive)" card in Settings > Models > Runtime. Two hosts back it: a `TmuxHost` (`src/agent/interactive_host/tmux.rs`) preferred on Unix when `tmux >= 3.4` is available and the flag is on, and a `SidecarHost` (`src/agent/interactive_host/sidecar.rs`) client that talks to the bundled `claudette-session-host` binary — required on Windows, opt-in fallback on Unix. Both implement the shared `InteractiveHost` trait (`src/agent/interactive_host/mod.rs`). The wire protocol between Tauri and the sidecar lives in `src/agent/interactive_protocol.rs` (length-prefixed JSON-line framing, request/event envelopes). Turn boundaries flow over Claude Code hooks: `claudette chat hook` (CLI subcommand) sends a `chat_hook` IPC request that the Tauri side routes via `AppState::dispatch_interactive_hook` into the matching per-session channel, which the frontend assembler (`useInteractiveTurnAssembler`) turns into per-turn xterm.js views (`InteractiveTurnView`). **Known limitation:** `SidecarHost` reconnect is not yet implemented — the cached `ConnHandle` in `OnceCell` is never reset if the underlying connection dies, so if the bundled `claudette-session-host` sidecar exits (e.g., due to its 600s idle timer) while Claudette is still running, subsequent `interactive_*` commands fail with "conn closed" errors until Claudette is restarted. Tracking as follow-up work. - **Terminal emulation**: portable-pty (Rust) + xterm.js (frontend) - **IPC**: Tauri commands (`#[tauri::command]`) for request/response, Tauri events for streaming ### Crate structure -Five crates in a Cargo workspace: +Six crates in a Cargo workspace: | Crate | Path | Purpose | |---|---|---| @@ -95,6 +97,7 @@ Five crates in a Cargo workspace: | `claudette-server` | `src-server/` | WebSocket server for remote access. Also embeddable in the Tauri binary. Uses `PersistentSession` so interactive controls (AskUserQuestion / ExitPlanMode) work over WSS. | | `claudette-cli` | `src-cli/` | Command-line client (`claudette` binary) that drives the running GUI over a local IPC socket. | | `claudette-mobile` | `src-mobile/` | Tauri 2 iOS / Android client — thin WSS remote-control app. Pairs with a running desktop or headless server; doesn't run agents locally. See `src-mobile/README.md` for the `cargo tauri ios init` setup. | +| `claudette-session-host` | `src-session-host/` | Sidecar binary that owns interactive Claude PTYs via `portable-pty` for the `ClaudeInteractive` harness. Required on Windows, opt-in fallback on Unix when tmux is unavailable or the user prefers it. Bundled as a Tauri `externalBin`; speaks the framed JSON protocol in `src/agent/interactive_protocol.rs`. | Feature flags in `claudette-tauri`: - `default = ["server", "voice", "devtools", "alternative-backends", "pi-sdk"]` @@ -110,14 +113,15 @@ Feature flags in `claudette-tauri`: - TypeScript enforces `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch` - Test runner is **vitest** (not Jest) - macOS uses overlay title bar (`titleBarStyle: "Overlay"`) — affects layout near top of window +- **Interactive Claude UI** lives in sibling files next to `ChatPanel.tsx` (which stays a god file — see below): `InteractiveTurnView` and `InteractiveTurns` render per-turn xterm.js views, `InteractiveTerminalMode` / `InteractiveTerminalModeToggle` provide the full-terminal embed, `useInteractiveTurnAssembler` assembles hook events into turns, `useInteractiveChatMode` picks the embedded-vs-terminal mode, and `services/interactive.ts` is the Tauri bridge (start / send_input / attach / capture_screen / stop and the hook-event subscription). ### Tauri commands -Commands in `src-tauri/src/commands/` are organized by domain: `agent_backends`, `apps`, `auth`, `boot`, `cesp`, `chat`, `claude_flags`, `cli`, `community`, `data`, `debug`, `devtools`, `diagnostics`, `dialog`, `diff`, `env`, `files`, `grammars`, `mcp`, `metrics`, `pinned_prompts`, `plan`, `plugin`, `plugins_runtime`, `remote`, `repository`, `scheduling`, `scm`, `settings`, `shell`, `slash_commands`, `storage`, `terminal`, `updater`, `usage`, `voice`, `workspace`. Each is a thin wrapper — business logic belongs in the `claudette` crate. +Commands in `src-tauri/src/commands/` are organized by domain: `agent_backends`, `apps`, `auth`, `boot`, `cesp`, `chat`, `claude_flags`, `cli`, `community`, `data`, `debug`, `devtools`, `diagnostics`, `dialog`, `diff`, `env`, `files`, `grammars`, `interactive`, `mcp`, `metrics`, `pinned_prompts`, `plan`, `plugin`, `plugins_runtime`, `remote`, `repository`, `scheduling`, `scm`, `settings`, `shell`, `slash_commands`, `storage`, `terminal`, `updater`, `usage`, `voice`, `workspace`. Each is a thin wrapper — business logic belongs in the `claudette` crate. ### CLI client -`claudette` (in `src-cli/`) drives the running GUI over a local IPC socket (Unix domain socket on macOS/Linux, Named Pipes on Windows; `interprocess` crate). It reuses the same command core as the GUI, so tray, notifications, and workspace list update live. Top-level subcommands include `version`, `capabilities`, `rpc`, `workspace` (alias `ws`), `chat`, `repo`, `batch`, `plugin`, `pr`, `routine`, and `completion`. IPC server lives in `src-tauri/src/ipc.rs`; CLI surface in `src-tauri/src/commands/cli.rs`. The CLI requires the GUI to be running — prefer it over poking the SQLite DB directly. +`claudette` (in `src-cli/`) drives the running GUI over a local IPC socket (Unix domain socket on macOS/Linux, Named Pipes on Windows; `interprocess` crate). It reuses the same command core as the GUI, so tray, notifications, and workspace list update live. Top-level subcommands include `version`, `capabilities`, `rpc`, `workspace` (alias `ws`), `chat`, `repo`, `batch`, `plugin`, `pr`, `routine`, and `completion`. `chat hook` is the hook target wired into the materialized Claude Code settings overlay for interactive sessions — it carries the hook kind (`UserPromptSubmit`, `Stop`, `AwaitingUserInput`, …) and the session id, and lands on the Tauri side as a `chat_hook` IPC method that `AppState::dispatch_interactive_hook` routes into the matching per-session channel. IPC server lives in `src-tauri/src/ipc.rs`; CLI surface in `src-tauri/src/commands/cli.rs`. The CLI requires the GUI to be running — prefer it over poking the SQLite DB directly. ## Project structure @@ -133,6 +137,11 @@ src/ plus alternative backends (`codex_app_server.rs`, `harness.rs` shared scaffolding). The Pi modules (`pi_sdk.rs`, `pi_control.rs`) are gated behind the lib crate's `pi-sdk` feature. + Interactive Claude (experimental): `claude_interactive.rs`, + `interactive_host/` (tmux + sidecar implementations of the + `InteractiveHost` trait, plus `availability.rs` / `conformance.rs`), + and `interactive_protocol.rs` (framed JSON wire types shared with + `claudette-session-host`). fork.rs — session forking / checkpoint branching snapshot.rs — workspace snapshots process.rs — cross-platform process spawning helpers @@ -174,8 +183,20 @@ src-pi-harness/ — TypeScript/Bun sidecar wrapping `@earendil-works/pi- shipped via Tauri `bundle.externalBin`. Glue lives in `src/agent/pi_sdk.rs`; only built/loaded when the `pi-sdk` feature is on. +src-session-host/ — `claudette-session-host` sidecar binary. Owns interactive Claude + PTYs via `portable-pty` and serves `EnsureSession` / `SendInput` / + `Attach` / `Resize` / `CaptureScreen` / `Stop` over the framed JSON + protocol in `src/agent/interactive_protocol.rs`. Required on + 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 @@ -188,6 +209,8 @@ A single sandboxed Lua runtime (`src/plugin_runtime/`) serves multiple plugin ki - **Plugins** (`src/ui/src/components/settings/sections/PluginsSettings.tsx`) — Claudette's own Lua plugins (SCM + env-provider). Always visible. Shows status, per-plugin toggle, and the manifest-declared settings form. - **Claude Code Plugins** (`ClaudeCodePluginsSettings.tsx`, route key `claude-code-plugins`) — the Claude CLI marketplace integration from `src/plugin.rs` (marketplaces, channels, install/uninstall). Always visible in the Settings sidebar nav. +**Experimental flags** persist as rows in `app_settings`. Current entries include `pluginManagementEnabled` (Claude Code Plugins section) and `claudeInteractiveEnabled` (Claude (Interactive) runtime card in Settings > Models > Runtime, the interactive PTY/host wiring described in the Architecture section, and the matching `claude_interactive` harness override in `effectiveHarness`). Add new flags via `ExperimentalSettings.tsx` so they show up under Settings > Experimental. + ### Guidelines for new code - **Data types** go in `model/` — keep them free of UI and IO dependencies. All model types must derive `Serialize`. @@ -205,7 +228,7 @@ These files are already large enough that adding more responsibility makes them - **Rust**: `src/diff.rs`, `src/git.rs`, `src/plugin.rs`, `src/mcp.rs`, `src/mcp_supervisor.rs`, `src-tauri/src/ipc.rs`, `src-tauri/src/voice.rs`, the largest siblings of the recently-split `src-tauri/src/commands/agent_backends/` directory (`gateway_translate.rs`, `runtime_dispatch.rs`, `discovery.rs`, `config.rs`), and anything else in `src-tauri/src/commands/*` that's already a few hundred lines. - **Frontend**: `src/ui/src/components/sidebar/Sidebar.tsx`, `src/ui/src/components/chat/ChatPanel.tsx`, `src/ui/src/components/chat/ChatInputArea.tsx`, `src/ui/src/components/terminal/TerminalPanel.tsx`, `src/ui/src/services/tauri.ts`, large CSS modules (e.g. `Settings.module.css`). -If a god file grew because the right home for the new behavior didn't exist yet, build that home first and land it as a separate (or stacked) commit before adding the new responsibility. +If a god file grew because the right home for the new behavior didn't exist yet, build that home first and land it as a separate (or stacked) commit before adding the new responsibility. Concrete example: the interactive-Claude UI lives in sibling files (`InteractiveTurnView`, `InteractiveTurns`, `InteractiveTerminalMode*`, `useInteractiveTurnAssembler`, `useInteractiveChatMode`, `services/interactive.ts`) and `ChatPanel.tsx` only branches into them — do the same for any future runtime-specific surface. ### Regression discipline diff --git a/Cargo.lock b/Cargo.lock index 2da6e8e00..93d1e2010 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" @@ -864,6 +889,7 @@ dependencies = [ "libc", "minisign-verify", "mlua", + "nix 0.30.1", "notify", "open", "rand 0.8.5", @@ -876,7 +902,9 @@ dependencies = [ "sha2", "tar", "tempfile", + "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-tungstenite", "tracing", "tracing-appender", @@ -953,6 +981,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "claudette-session-host" +version = "0.25.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", + "whoami", +] + [[package]] name = "claudette-tauri" version = "0.25.0" @@ -1002,6 +1052,7 @@ dependencies = [ "tokenizers", "tokio", "tokio-rustls", + "tokio-stream", "tokio-tungstenite", "tracing", "tracing-subscriber", @@ -1311,6 +1362,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" @@ -2761,6 +2818,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" @@ -3722,6 +3788,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" @@ -3857,6 +3932,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" @@ -4776,7 +4863,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.25.1", "serial", "shared_library", "shell-words", @@ -5461,6 +5548,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" @@ -6025,6 +6123,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" @@ -6221,6 +6329,10 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "stub-tui" +version = "0.25.0" + [[package]] name = "subtle" version = "2.6.1" @@ -7073,6 +7185,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" @@ -7541,6 +7664,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" @@ -7681,6 +7810,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" @@ -7730,6 +7873,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" @@ -8059,6 +8208,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/Cargo.toml b/Cargo.toml index 19a3819e4..a10bb40ae 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", "src-session-host", "tests/fixtures/stub-tui"] resolver = "2" [package] @@ -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 @@ -102,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/scripts/check-coverage-interactive.sh b/scripts/check-coverage-interactive.sh new file mode 100755 index 000000000..fb93c157d --- /dev/null +++ b/scripts/check-coverage-interactive.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Gate the Claude Interactive patch surface on `cargo llvm-cov` line coverage. +# +# This script parses the JSON produced by `scripts/coverage-interactive.sh` +# (or the equivalent CI step), filters down to the interactive-Claude file +# set, applies a documented exclusion list for files that are structurally +# untestable in CI (see EXCLUDED_PATTERNS below), and computes an aggregate +# line-coverage percentage. The threshold is enforced when +# COVERAGE_GATE_BLOCKING=1 — otherwise the script reports the number, exits +# 0, and CI surfaces the value as informational. +# +# See CLAUDE.md ("Build & test commands") and the interactive-claude +# coverage plan (Task A2 + Task G1) for context. +set -euo pipefail + +json_path="${1:-target/llvm-cov-interactive.json}" +threshold="${COVERAGE_GATE_THRESHOLD:-85}" +blocking="${COVERAGE_GATE_BLOCKING:-0}" + +if [ ! -f "$json_path" ]; then + echo "error: coverage JSON not found at $json_path" >&2 + echo " run scripts/coverage-interactive.sh first" >&2 + exit 2 +fi + +# Files that make up the Claude Interactive patch surface. Keep this in +# sync with scripts/coverage-interactive.sh and the coverage plan. +patterns=( + "src/agent/interactive_host/" + "src/agent/interactive_protocol.rs" + "src/agent/claude_interactive.rs" + "src/interactive.rs" + "src/db/interactive_sessions.rs" + "src-session-host/src/" + "src-tauri/src/commands/interactive.rs" + "src-tauri/src/interactive_lifecycle.rs" +) + +# Files explicitly excluded from the gate because they are structurally +# untestable in CI. Every entry below MUST be accompanied by a rationale — +# do not add files here just to make the number go up. +# +# interactive_host/tmux.rs +# The TmuxHost impl talks to a real `tmux >= 3.0` binary. CI runners +# don't have tmux installed; all live tests in this file are +# `#[ignore = "requires tmux >= 3.0"]`. Helper-function tests +# (shell_escape) still run, but the bulk of the file is unreachable +# without the binary. +# +# interactive_host/sidecar.rs +# The SidecarHost conformance suite spawns the bundled +# `claudette-session-host` binary via `cargo build -p ...` and is +# `#[ignore = "spawned-sidecar conformance test — run with --ignored"]`. +# The unit-test coverage we DO get exercises the framing / handshake +# edges; the request/response handlers are validated through the +# session-host integration tests in `src-session-host/tests/*`. +# +# interactive_host/conformance.rs +# This file *is* a test harness — it defines the conformance suite +# that tmux + sidecar impls both share, but its lines only execute +# when one of the ignored conformance tests runs. +# +# interactive_host/mod.rs +# Trait definition + `select_default_host`. The dispatcher requires +# a real host on PATH; trait dispatch is exercised everywhere else +# and contributes no semantic logic of its own. +# +# src-session-host/src/main.rs +# The session-host binary entry point. Wires Args + tokio runtime + +# `run_server` together; covered indirectly by the spawned-binary +# integration tests in `src-session-host/tests/*`. +# +# src-session-host/src/server.rs +# Local-socket accept loop + per-connection request dispatch. Most +# handlers require a live `claude` binary attached to a session +# (e.g. SendInput → tmux pipe-pane → claude PTY). The session-host +# integration tests in `src-session-host/tests/*` exercise the +# handshake + idle-exit paths but stop short of EnsureSession ↔ +# real claude, so the request-dispatch arms stay at ~50%. +# +# When the structural reason for an exclusion is gone (e.g. CI grows tmux, +# or the conformance suite is wired through a stub TUI), DELETE the line +# from this list rather than letting the comment go stale. +excluded_patterns=( + "src/agent/interactive_host/tmux.rs" + "src/agent/interactive_host/sidecar.rs" + "src/agent/interactive_host/conformance.rs" + "src/agent/interactive_host/mod.rs" + "src-session-host/src/main.rs" + "src-session-host/src/server.rs" +) + +# Build a single jq expression of the form +# (.filename | test("p1")) or (.filename | test("p2")) or ... +jq_filter="" +for p in "${patterns[@]}"; do + # Escape regex meta-characters for jq's `test` (we treat each pattern as + # a literal substring match). jq uses PCRE2 — `/` doesn't need escaping + # and over-escaping it ("\/") raises an "Invalid escape" compile error. + escaped="$(printf '%s' "$p" | sed -e 's/[][().|^$*+?{}\\]/\\&/g' -e 's/\./\\./g')" + if [ -z "$jq_filter" ]; then + jq_filter="(.filename | test(\"$escaped\"))" + else + jq_filter="$jq_filter or (.filename | test(\"$escaped\"))" + fi +done + +# Build the exclusion clause: NOT (any of the excluded patterns). +jq_exclude="" +for p in "${excluded_patterns[@]}"; do + escaped="$(printf '%s' "$p" | sed -e 's/[][().|^$*+?{}\\]/\\&/g' -e 's/\./\\./g')" + if [ -z "$jq_exclude" ]; then + jq_exclude="(.filename | test(\"$escaped\"))" + else + jq_exclude="$jq_exclude or (.filename | test(\"$escaped\"))" + fi +done +jq_filter="($jq_filter) and (($jq_exclude) | not)" + +# Aggregate covered + total lines across the filtered files. We deliberately +# compute the ratio ourselves rather than averaging per-file percentages so +# a small fully-covered file can't drown out a large undertested one. +read -r covered total matched <&2 + echo " check that the llvm-cov run included the right crates" >&2 + exit 2 +fi + +if [ "${total:-0}" -eq 0 ]; then + echo "error: matched $matched file(s) but total line count is 0" >&2 + exit 2 +fi + +percent=$(awk -v c="$covered" -v t="$total" 'BEGIN { printf "%.2f", (c / t) * 100 }') + +printf 'interactive patch coverage: %s%% (%s/%s lines across %s files, threshold=%s%%)\n' \ + "$percent" "$covered" "$total" "$matched" "$threshold" + +# Per-file breakdown for diagnostic visibility (sorted lowest coverage first). +jq -r " + .data[0].files[] + | select($jq_filter) + | [ + (.summary.lines.percent | tonumber | (. * 100 | round) / 100), + .summary.lines.covered, + .summary.lines.count, + .filename + ] + | @tsv +" "$json_path" \ + | sort -n \ + | awk -F '\t' '{ printf " %6.2f%% %4d/%4d %s\n", $1, $2, $3, $4 }' + +# Also emit a summary of excluded files so the omission is visible in CI +# logs and reviewable in PRs — drift here is exactly what stale-comment +# exclusion lists do over time. +echo "" +echo "excluded from gate (see scripts/check-coverage-interactive.sh for rationale):" +jq -r " + .data[0].files[] + | select($jq_exclude) + | [ + (.summary.lines.percent | tonumber | (. * 100 | round) / 100), + .summary.lines.covered, + .summary.lines.count, + .filename + ] + | @tsv +" "$json_path" \ + | sort -n \ + | awk -F '\t' '{ printf " %6.2f%% %4d/%4d %s\n", $1, $2, $3, $4 }' + +if awk -v p="$percent" -v t="$threshold" 'BEGIN { exit (p + 0 < t + 0) ? 0 : 1 }'; then + if [ "$blocking" = "1" ]; then + echo "error: interactive patch coverage ${percent}% < ${threshold}% threshold" >&2 + exit 1 + else + echo "warn: interactive patch coverage ${percent}% < ${threshold}% threshold (informational; set COVERAGE_GATE_BLOCKING=1 to enforce)" >&2 + fi +fi diff --git a/scripts/coverage-interactive.sh b/scripts/coverage-interactive.sh new file mode 100755 index 000000000..c2bae78ae --- /dev/null +++ b/scripts/coverage-interactive.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Produce the JSON `cargo llvm-cov` report consumed by +# `scripts/check-coverage-interactive.sh` for the Claude Interactive +# patch-coverage gate. +# +# Crate set: +# - `claudette` — the lib that owns the bulk of the interactive +# surface (claude_interactive.rs, interactive.rs, +# interactive_host/*, interactive_protocol.rs, +# db/interactive_sessions.rs). +# - `claudette-server` — pulled in for parity with the existing CI +# coverage step. No interactive files live here +# today but the package set must match so the +# toolchain assumptions stay consistent. +# - `claudette-cli` — same rationale. +# - `claudette-session-host` — owns the sidecar host side of the +# interactive protocol (server.rs, session.rs, +# idle.rs). Its clippy IS in CI per CLAUDE.md. +# +# Notably NOT included: `claudette-tauri`. Per CLAUDE.md it requires +# system libs not installed on the Linux CI runner, so we can't run its +# tests under llvm-cov in CI. The interactive command wrappers in +# `src-tauri/src/commands/interactive.rs` + `interactive_lifecycle.rs` +# are tested locally per the dev-machine checklist and excluded from the +# gate. See `scripts/check-coverage-interactive.sh` for the file +# filtering and the documented per-file exclusions. +# +# See CLAUDE.md ("Build & test commands") and the interactive-claude +# coverage plan (Task A2 + Task G1) for context. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +output_path="${1:-target/llvm-cov-interactive.json}" +mkdir -p "$(dirname "$output_path")" + +cargo llvm-cov \ + -p claudette -p claudette-server -p claudette-cli -p claudette-session-host \ + --all-features \ + --json \ + --output-path "$output_path" + +echo "wrote llvm-cov JSON: $output_path" diff --git a/scripts/stage-cli-sidecar.sh b/scripts/stage-cli-sidecar.sh index f8bb62475..d0ad5faf5 100755 --- a/scripts/stage-cli-sidecar.sh +++ b/scripts/stage-cli-sidecar.sh @@ -129,3 +129,18 @@ 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") +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/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/site/astro.config.mjs b/site/astro.config.mjs index cc8304e32..1573f7188 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -135,6 +135,7 @@ export default defineConfig({ { slug: 'features/settings' }, { slug: 'features/diagnostics' }, { slug: 'features/experimental-features' }, + { slug: 'features/interactive-claude' }, { slug: 'features/community-registry-trust' }, { slug: 'privacy' }, ], diff --git a/site/src/content/docs/features/interactive-claude.mdx b/site/src/content/docs/features/interactive-claude.mdx new file mode 100644 index 000000000..23a2d426b --- /dev/null +++ b/site/src/content/docs/features/interactive-claude.mdx @@ -0,0 +1,79 @@ +--- +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). diff --git a/site/src/content/docs/features/settings.mdx b/site/src/content/docs/features/settings.mdx index f1e007c45..8ba930728 100644 --- a/site/src/content/docs/features/settings.mdx +++ b/site/src/content/docs/features/settings.mdx @@ -57,6 +57,7 @@ See [Agent Configuration](/claudette/features/agent-configuration/) for detailed | Setting | Description | Default | |---------|-------------|---------| | Claude Code Usage | Surface Claude Pro / Max subscription quotas (5-hour session, weekly all-model, weekly Sonnet/Opus, extra usage) in the composer's usage meter. The meter itself is always visible for Codex / OpenAI / OpenRouter / Pi / Ollama / LM Studio sessions (driven by local-aggregate token totals); this toggle only controls whether Claude-family sessions surface real subscription buckets versus a greyed-out click-to-enable affordance. Confirmation dialog warns about Anthropic ToS on enable. | Off | +| Claude (Interactive) | Enables the Claude (Interactive) experimental backend. See [Claude (Interactive)](/claudette/features/interactive-claude/). | Off | **Claude Code Plugins** and **Community** are their own settings sections (visible by default in the Settings sidebar) rather than flat toggle lists — see [Community Registry & Trust](/claudette/features/community-registry-trust/) for the Community section. Claude Remote Control is reached from the chat composer's overflow menu, not Settings. Legacy Experimental off values for these graduated features are ignored after upgrading and no longer hide their surfaces. 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..25aebbc04 --- /dev/null +++ b/src-cli/src/commands/chat_hook.rs @@ -0,0 +1,260 @@ +//! `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" + ); + } + + /// Build an [`AppInfo`] whose `socket` points at the supplied path. + /// `pid` is set to the current process — `discovery::pid_alive` + /// isn't consulted by the IPC path; the GUI's socket is the only + /// thing that matters here. + fn fake_app_info(socket: &str) -> AppInfo { + AppInfo { + pid: std::process::id(), + socket: socket.to_string(), + token: "test-token".to_string(), + app_version: String::new(), + started_at: String::new(), + } + } + + /// Allocate a unique path under the OS temp dir. Uses the current + /// PID and an atomic counter so parallel tests can't collide. + fn unique_temp_path(label: &str) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "claudette-chat-hook-test-{label}-{}-{n}", + std::process::id() + )) + } + + /// Step 1: socket path doesn't exist on disk. The CLI must surface + /// a `Connect` error (not a panic, not a transport-mid-stream + /// error) with a user-readable message — the user needs to be + /// told the GUI socket is gone, not handed a stack trace. + #[tokio::test] + async fn chat_hook_fails_clearly_when_socket_missing() { + let missing = unique_temp_path("missing"); + // Sanity: the path must not exist before we dial it. If a + // previous test run somehow left a file here, scrub it so the + // assertion below tests what it claims to. + let _ = std::fs::remove_file(&missing); + assert!( + !missing.exists(), + "precondition: socket path must not exist" + ); + + let info = fake_app_info(missing.to_str().unwrap()); + let args = ChatHookArgs { + sid: "sid-test".to_string(), + kind: "stop".to_string(), + reason: None, + }; + + let err = run(&info, args) + .await + .expect_err("missing socket must error"); + let message = err.to_string(); + // The error should clearly indicate a connection failure — + // "connect failed: ..." is what `CallError::Connect` renders. + // We assert on the user-facing string so a future refactor + // that swallows or reformats this message gets caught. + assert!( + message.to_lowercase().contains("connect"), + "error must mention connect failure, got: {message}" + ); + // And it must not be empty / placeholder. + assert!( + message.len() > 10, + "error message must be user-readable, got: {message:?}" + ); + } + + /// Step 2: a regular file exists at the socket path, but nothing + /// is listening on it (stale socket file). On Unix this is the + /// "Claudette crashed without unlinking" shape. The CLI must + /// still report a connect-level error, not silently succeed and + /// not hang. + #[tokio::test] + async fn chat_hook_fails_clearly_on_stale_socket_path() { + // Create a regular file (not a Unix socket) at the path. + // `interprocess`'s connect will refuse it on every supported + // platform — Unix because the inode isn't `SOCK_STREAM`, and + // Windows because the path doesn't name a live pipe. + let stale = unique_temp_path("stale"); + std::fs::write(&stale, b"not a socket").expect("setup: write stale placeholder file"); + + // Guard the temp file with a drop helper so the test cleans + // up even if it panics partway through. + struct Cleanup<'a>(&'a std::path::Path); + impl Drop for Cleanup<'_> { + fn drop(&mut self) { + let _ = std::fs::remove_file(self.0); + } + } + let _cleanup = Cleanup(&stale); + + let info = fake_app_info(stale.to_str().unwrap()); + let args = ChatHookArgs { + sid: "sid-test".to_string(), + kind: "stop".to_string(), + reason: None, + }; + + let err = run(&info, args).await.expect_err("stale socket must error"); + let message = err.to_string(); + assert!( + message.to_lowercase().contains("connect"), + "stale-socket error must mention connect failure, got: {message}" + ); + } +} 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-session-host/Cargo.toml b/src-session-host/Cargo.toml new file mode 100644 index 000000000..17c7f5b70 --- /dev/null +++ b/src-session-host/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "claudette-session-host" +version = "0.25.0" +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" + +[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" +# 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..c7ceef41f --- /dev/null +++ b/src-session-host/src/idle.rs @@ -0,0 +1,127 @@ +//! Idle-shutdown timer for the session host. +//! +//! 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/lib.rs b/src-session-host/src/lib.rs new file mode 100644 index 000000000..5613fb042 --- /dev/null +++ b/src-session-host/src/lib.rs @@ -0,0 +1,15 @@ +//! 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; + +/// 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/main.rs b/src-session-host/src/main.rs new file mode 100644 index 000000000..728c360ab --- /dev/null +++ b/src-session-host/src/main.rs @@ -0,0 +1,69 @@ +//! 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`. +//! +//! 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}; + +/// 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); + +/// 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() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let socket_path = parse_socket_arg().unwrap_or_else(server::default_socket_path); + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + socket = %socket_path.display(), + idle_timeout_secs = IDLE_TIMEOUT.as_secs(), + "claudette-session-host starting" + ); + + 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 new file mode 100644 index 000000000..da7f87e18 --- /dev/null +++ b/src-session-host/src/server.rs @@ -0,0 +1,544 @@ +//! 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. +//! +//! After the handshake the connection enters a request/response loop. Task C3 +//! 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::{ + Event, InboundFrame, PROTOCOL_VERSION, Request, RequestEnvelope, Response, StopMode, + 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}; +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. +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. +/// +/// 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` 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, 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 +/// `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_with(new_session_map(), socket_path).await +} + +async fn serve(listener: Listener, map: SessionMap, idle: Option) -> std::io::Result<()> { + loop { + let stream = match listener.accept().await { + Ok(s) => s, + Err(e) => { + tracing::warn!(?e, "accept failed"); + continue; + } + }; + 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"); + } + }); + } +} + +async fn handle_connection(stream: Stream, map: SessionMap) -> std::io::Result<()> { + let (mut r, mut w) = stream.split(); + // 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 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, .. + } = env.request + else { + 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, + &serde_json::to_vec(&bad).map_err(std::io::Error::other)?, + ) + .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], + } + }; + let outbound = InboundFrame::Response { + request_id: hello_request_id, + response: resp, + }; + write_frame( + &mut w, + &serde_json::to_vec(&outbound).map_err(std::io::Error::other)?, + ) + .await?; + + // 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. 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, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), + Err(e) => return Err(e), + }; + let env: RequestEnvelope = match serde_json::from_slice(&frame_bytes) { + Ok(v) => v, + Err(e) => { + // 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, + &serde_json::to_vec(&r).map_err(std::io::Error::other)?, + ) + .await?; + continue; + } + }; + 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 = InboundFrame::Response { + request_id, + response: 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 = InboundFrame::Response { + request_id, + response: 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; + let outbound = InboundFrame::Response { + request_id, + response: resp, + }; + write_frame( + &mut w, + &serde_json::to_vec(&outbound).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). +/// +/// 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. `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(); + 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 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; + } + } + 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(()) +} + +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 } => { + // 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, + cols, + }; + } + 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; + { + let mut m = map.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 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, + }; + }; + 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); + // `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 } + } + 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; + // 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 { + 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/src/session.rs b/src-session-host/src/session.rs new file mode 100644 index 000000000..2bc8e848d --- /dev/null +++ b/src-session-host/src/session.rs @@ -0,0 +1,315 @@ +//! Per-session PTY ownership and event fan-out. +//! +//! 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. + // + // ## Why `Ok(0)` collapses with `Err(_)` here (no spurious-zero handling) + // + // The `TmuxHost` FIFO tailer treats `Ok(0)` as potentially spurious and + // confirms session death via `tmux has-session` before bailing — tmux's + // pipe-pane can briefly close the writer fd on `respawn-pane` and + // similar without the session actually ending. + // + // The `portable-pty` master reader has no such surface: a zero-byte + // read on the master happens only when every slave fd has been closed, + // i.e. the child process is gone (or has explicitly closed its + // stdin/stdout/stderr). There is no analogue of tmux's transient + // detach. Treating `Ok(0)` as terminal is therefore correct, and the + // "reader sees zero while child is still alive" branch is not + // reachable from a stable test — covered by documentation rather than + // a unit test (see Task C1, plan + // `2026-05-18-interactive-claude-coverage-plan.md`). + 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. + // + // The `Err(e)` arm below covers `portable_pty::Child::wait` failing, + // which on every platform we ship to (Unix `waitpid`, Windows + // `WaitForSingleObject` on the process handle) only fails on internal + // bookkeeping errors (lost child handle, ECHILD because someone else + // reaped the child, etc.). The `Child` here is freshly returned from + // `spawn_command` and held exclusively by this task — none of those + // conditions are reproducible in a unit test. We keep the arm so a + // surprise failure still surfaces as a synthetic Exit instead of + // silently dropping the waiter; the path is treated as + // known-unreachable for coverage (see Task C1, plan + // `2026-05-18-interactive-claude-coverage-plan.md`, and the + // `wait_err_path_is_unreachable` test below). + 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()); + } + + /// Documents that the reader's `Ok(0)` arm is not a spurious-zero path: + /// `portable-pty`'s master reader returns zero only on real slave EOF, so + /// "child still alive while reader sees zero" is not a reachable state + /// for the `SidecarHost` reader (unlike `TmuxHost`'s pipe-pane FIFO, + /// which can transiently close on `respawn-pane`). See the Reader task + /// comment in `Session::spawn` and Task C1 in + /// `superpowers/plans/2026-05-18-interactive-claude-coverage-plan.md`. + /// + /// Kept as an `#[ignore]`d test so the rationale lives next to the code + /// and grep finds it. + #[test] + #[ignore = "documents an unreachable code path; see comment + plan"] + fn reader_ok_zero_is_not_spurious_on_portable_pty() { + // Intentionally empty — see the doc comment above. + } + + /// Documents that the waiter's `Err(_)` arm covers a + /// known-unreachable-in-practice failure mode of + /// `portable_pty::Child::wait`. The arm exists so that surprise failures + /// still surface as a synthetic `SessionEvent::Exit { exit_status: -1, … + /// reason: "wait failed: " }` instead of silently dropping the waiter. + /// See the Waiter task comment in `Session::spawn` and Task C1 in + /// `superpowers/plans/2026-05-18-interactive-claude-coverage-plan.md`. + #[test] + #[ignore = "documents an unreachable code path; see comment + plan"] + fn wait_err_path_is_unreachable() { + // Intentionally empty — see the doc comment above. + } +} diff --git a/src-session-host/tests/attach_stream.rs b/src-session-host/tests/attach_stream.rs new file mode 100644 index 000000000..675a6cea0 --- /dev/null +++ b/src-session-host/tests/attach_stream.rs @@ -0,0 +1,409 @@ +#![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 std::sync::atomic::{AtomicU64, Ordering}; + +use base64::Engine as _; +use claudette::agent::interactive_protocol::{ + 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(); + + 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(10)).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(10)).await; + assert!(seen, "did not observe echoed line on attach stream"); + + server.abort(); + let _ = std::fs::remove_file(&socket); +} + +/// Drive the broadcast channel past its 2048-slot capacity with one fast +/// consumer draining and one slow consumer falling behind, then assert the +/// slow consumer's attach stream terminates. +/// +/// The session host wires every attach connection to the per-session +/// `broadcast::Sender` (capacity 2048 in `Session::spawn`). When +/// a subscriber falls more than 2048 messages behind the writer, `rx.recv()` +/// returns `RecvError::Lagged(n)`. `stream_attach` logs a warn and breaks out +/// of its pump loop — from the client's perspective the attach socket just +/// hits EOF cleanly. We can't assert against `tracing` output portably, so we +/// assert the observable contract instead: the slow attach reaches stream +/// end (EOF on `read_frame`) within a bounded time window. +/// +/// The fast consumer is drained in a background task to keep its +/// `stream_attach` write loop unblocked — without it the server would apply +/// socket-write backpressure on the fast attach and we wouldn't have a clean +/// reference subscriber to compare against. The slow consumer reads at a +/// throttled cadence (one frame per `SLOW_DELAY_MS`) so its per-attach task +/// keeps cycling through `rx.recv()` rather than getting stuck on +/// `write_frame` once its OS send buffer fills — without that pacing the +/// `Lagged` arm would never be reached because `recv()` is never called. +#[tokio::test] +async fn attach_lagged_subscriber_stream_ends() { + let stub = find_stub_tui(); + + let socket = std::env::temp_dir().join(format!("attach-lag-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; + + // Control connection. + 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-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 { + 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![("STUB_TUI_DELAY_MS".into(), "200".into())], + 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), + other => panic!("expected SessionStarted, got {other:?}"), + } + + // Fast attach: drained continuously by a background task. + let fast = open_conn(&socket).await; + let (mut fast_r, mut fast_w) = fast.split(); + handshake(&mut fast_r, &mut fast_w).await; + send_req(&mut fast_w, &Request::Attach { sid: sid.clone() }).await; + match recv_resp(&mut fast_r).await { + Response::AttachStarted { .. } => {} + other => panic!("expected AttachStarted on fast attach, got {other:?}"), + } + // Wait for stub-tui's READY before kicking off the drainer, to make sure + // the session reader task is wired up before we start blasting input. + assert!( + drain_until_contains(&mut fast_r, "READY", Duration::from_secs(10)).await, + "fast attach never saw READY" + ); + + let fast_drainer = tokio::spawn(async move { + // Read frames as fast as they arrive; ignore content. Exits when + // the connection closes (server abort at end of test). + while frame::read_frame(&mut fast_r).await.is_ok() {} + }); + + // Slow attach: handshake + Attach, then read at a deliberately slow + // rate. We can't simply never read — the server's per-attach task + // would block on `write_frame` (TCP send buffer full) and never call + // `rx.recv()` again, so the `Lagged` arm would be unreachable. By + // pacing reads slower than production we keep the server task cycling + // through `recv()` while the producer outpaces it; eventually `recv()` + // returns `Lagged` and the server logs the warn + closes the stream. + let slow = open_conn(&socket).await; + let (mut slow_r, mut slow_w) = slow.split(); + handshake(&mut slow_r, &mut slow_w).await; + send_req(&mut slow_w, &Request::Attach { sid: sid.clone() }).await; + match recv_resp(&mut slow_r).await { + Response::AttachStarted { .. } => {} + other => panic!("expected AttachStarted on slow attach, got {other:?}"), + } + + // The slow drainer reads at most one frame every `SLOW_DELAY_MS` ms + // and returns the first read error it encounters (the lag-induced + // close). It hands back the count of frames seen so a regression + // toward "no frames at all" is also visible in the assertion below. + const SLOW_DELAY_MS: u64 = 25; + let slow_drainer = tokio::spawn(async move { + let mut frames_seen: u64 = 0; + loop { + tokio::time::sleep(Duration::from_millis(SLOW_DELAY_MS)).await; + match frame::read_frame(&mut slow_r).await { + Ok(_) => frames_seen += 1, + Err(_) => return frames_seen, + } + } + }); + + // Push enough output through stub-tui to overflow the 2048-slot + // broadcast channel. Each iteration sends a small line and awaits its + // `Ok` reply (so the test client never falls behind on responses and + // we never block on socket buffers). The PTY reader produces one + // broadcast event per read; with 4096 line-by-line sends the producer + // far outpaces the 25 ms-per-frame slow drainer, eventually lapping + // it past the 2048-slot window. + const ITERATIONS: usize = 4096; + for i in 0..ITERATIONS { + send_req( + &mut ctrl_w, + &Request::SendInput { + sid: sid.clone(), + payload: InputPayload::Text { + text: format!("l{i}\n"), + }, + }, + ) + .await; + let resp = recv_resp(&mut ctrl_r).await; + assert!( + matches!(resp, Response::Ok), + "expected Ok for SendInput at iteration {i}, got {resp:?}" + ); + } + + // The slow drainer should observe its stream close once the broadcast + // overruns it. Give it a generous timeout — production might continue + // for a moment after the loop above as PTY echoes drain. + let frames_seen = tokio::time::timeout(Duration::from_secs(20), slow_drainer) + .await + .expect("slow drainer never reported stream end — lagged-broadcast path not exercised") + .expect("slow drainer task panicked"); + assert!( + frames_seen > 0, + "slow drainer never saw any frames before close — fast attach pipeline may be broken" + ); + + fast_drainer.abort(); + 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 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(); +} + +async fn recv_resp(r: &mut R) -> Response +where + R: tokio::io::AsyncRead + Unpin, +{ + let buf = frame::read_frame(r).await.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, +{ + 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 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) + .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 +} diff --git a/src-session-host/tests/ensure_session.rs b/src-session-host/tests/ensure_session.rs new file mode 100644 index 000000000..476a7505b --- /dev/null +++ b/src-session-host/tests/ensure_session.rs @@ -0,0 +1,227 @@ +#![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::{ + 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() { + 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:?}"), + } + + // 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); +} + +// -- helpers --------------------------------------------------------------- + +async fn open_conn(path: &Path) -> Stream { + let name = path.to_fs_name::().unwrap(); + 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 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(); + 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) +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 +} diff --git a/src-session-host/tests/handshake.rs b/src-session-host/tests/handshake.rs new file mode 100644 index 000000000..7fa6f83bd --- /dev/null +++ b/src-session-host/tests/handshake.rs @@ -0,0 +1,134 @@ +#![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, +//! sends `Request::Hello`, and asserts that the server replies with +//! `Response::HelloAck` for the current `PROTOCOL_VERSION`. + +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}; + +#[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(); + + // 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 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(); + let _ = std::fs::remove_file(&socket_path); +} + +#[tokio::test] +async fn handshake_rejects_unsupported_protocol_version() { + let socket_path = std::env::temp_dir().join(format!( + "claudette-handshake-mismatch-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(); + + // Send a Hello with a protocol_version the server does not support. + let env = RequestEnvelope { + request_id: 0, + request: Request::Hello { + protocol_version: 999, + 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 inbound: InboundFrame = serde_json::from_slice(&resp_bytes).unwrap(); + match inbound { + InboundFrame::Response { + request_id, + response: Response::HelloNack { + supported_versions, .. + }, + } => { + assert_eq!(request_id, 0, "Nack reply should echo request_id 0"); + assert_eq!( + supported_versions, + vec![PROTOCOL_VERSION], + "HelloNack should advertise only the server's supported protocol version" + ); + } + other => panic!("expected InboundFrame::Response(HelloNack), got {other:?}"), + } + + server.abort(); + let _ = std::fs::remove_file(&socket_path); +} 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:?}" + ); +} 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/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/mod.rs b/src-tauri/src/commands/agent_backends/mod.rs index 63b640f90..f462a0671 100644 --- a/src-tauri/src/commands/agent_backends/mod.rs +++ b/src-tauri/src/commands/agent_backends/mod.rs @@ -244,16 +244,23 @@ pub async fn save_agent_backend( /// /// `harness` is `None` to clear the override (the resolver falls back /// to `AgentBackendKind::default_harness`). A `Some` value must be in -/// the kind's `available_harnesses` list — otherwise the call is -/// rejected so a malicious frontend cannot bypass the per-kind matrix. -/// The built-in Anthropic backend always rejects overrides because its -/// kind only permits the Claude CLI harness. +/// the kind's allow-list — otherwise the call is rejected so a +/// malicious frontend cannot bypass the per-kind matrix. The built-in +/// Anthropic backend rejects every override except `ClaudeInteractive` +/// (and only when the `claudeInteractiveEnabled` experimental flag is +/// on), since its kind only permits Claude-side harnesses. +/// +/// The flag is read from `app_settings` rather than passed by the +/// caller so a stale frontend can't trick the validator into accepting +/// `ClaudeInteractive` while the gate is off — same surface +/// `AgentBackendConfig::effective_harness_kind` uses for dispatch. #[tauri::command] pub async fn set_agent_backend_runtime_harness( backend_id: String, harness: Option, state: State<'_, AppState>, ) -> Result, String> { + let claude_interactive_enabled = state.claude_interactive_enabled().await; let db = Database::open(&state.db_path).map_err(|e| e.to_string())?; ensure_backend_id_allowed_by_gate(&db, &backend_id)?; let mut backends = load_backend_configs(&db)?; @@ -262,7 +269,10 @@ pub async fn set_agent_backend_runtime_harness( .find(|backend| backend.id == backend_id) .ok_or_else(|| format!("Unknown backend `{backend_id}`"))?; if let Some(harness) = harness { - if !slot.kind.available_harnesses().contains(&harness) { + let allowed = slot + .kind + .available_harnesses_with_interactive(claude_interactive_enabled); + if !allowed.contains(&harness) { return Err(format!( "Backend `{}` ({:?}) does not allow harness `{:?}`", slot.id, slot.kind, harness @@ -716,6 +726,7 @@ mod tests { status_line: String::new(), created_at: "2026-05-22T00:00:00.000Z".to_string(), sort_order: 0, + input_values: None, }; assert_eq!(codex_login_worktree_path(&workspace), None); 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-tauri/src/commands/interactive.rs b/src-tauri/src/commands/interactive.rs new file mode 100644 index 000000000..f941ada82 --- /dev/null +++ b/src-tauri/src/commands/interactive.rs @@ -0,0 +1,1602 @@ +//! 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, HookEventKind, InteractiveHookEvent}; + +/// 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. +/// +/// `last_screen_blob` carries the most recently captured ANSI screen +/// bytes (or `None` if `interactive_capture_screen` was never called +/// for this sid). Tauri serializes `Vec` as a JSON number array, +/// matching the `lastScreenBlob: number[] | null` declared by the +/// TypeScript `InteractiveSessionRow`. +#[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 last_screen_blob: 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, + last_screen_blob: row.last_screen_blob, + 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] + } +} + +/// Callback used by [`interactive_start_inner`] to forward CLI-relayed +/// hook events to the frontend. Production wires this to +/// `AppHandle::emit` for `interactive:///hook`; tests inject a +/// closure that records each emission so the test can assert the +/// channel was wired up. +/// +/// Takes the topic + payload separately so the production wrapper can +/// produce the `format!("interactive://{sid}/hook", …)` topic and the +/// test wrapper can record it as-is. +pub(crate) type HookForwardEmitter = Arc; + +/// 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( + app: AppHandle, + state: State<'_, AppState>, + args: StartInteractiveArgs, +) -> Result { + let emitter: HookForwardEmitter = { + let app = app.clone(); + Arc::new(move |topic: &str, payload: &serde_json::Value| { + let _ = app.emit(topic, payload); + }) + }; + interactive_start_inner(state.inner(), args, emitter).await +} + +/// Testable core of [`interactive_start`]. Identical behavior, but +/// takes a borrowed `AppState` (the same managed instance the +/// production command hands in) plus a callback that emits the +/// `interactive:///hook` Tauri event payload. +/// +/// Splitting this out lets the unit tests in `mod tests` exercise the +/// flag-gate / DB persistence / sid-registration / hook-channel +/// wiring branches without constructing a real `AppHandle` (which +/// requires a full Tauri runtime). +pub(crate) async fn interactive_start_inner( + state: &AppState, + args: StartInteractiveArgs, + emit_hook: HookForwardEmitter, +) -> 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; + + // 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 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), + }); + emit_hook(&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 +/// `InputPayload::Text` envelope over the sidecar socket. +#[tauri::command] +pub async fn interactive_send_input( + state: State<'_, AppState>, + sid: String, + text: String, +) -> Result<(), String> { + interactive_send_input_inner(state.inner(), sid, text).await +} + +/// Testable core of [`interactive_send_input`]. See that function for +/// the contract; the only difference is the borrowed `&AppState` so +/// tests can construct a state value directly. +pub(crate) async fn interactive_send_input_inner( + 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 { + interactive_capture_screen_inner(state.inner(), sid).await +} + +/// Testable core of [`interactive_capture_screen`]. See that function +/// for the contract; the only difference is the borrowed `&AppState` +/// so tests can construct a state value directly. +pub(crate) async fn interactive_capture_screen_inner( + 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 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. + // 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())?; + + // 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)) +} + +/// 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 = "stopped"` 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> { + interactive_stop_inner(state.inner(), sid, force).await +} + +/// Testable core of [`interactive_stop`]. See that function for the +/// contract; the only difference is the borrowed `&AppState` so tests +/// can construct a state value directly. +pub(crate) async fn interactive_stop_inner( + 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 persist_result = 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, "stopped", 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())?; + + // Surface DB-write failures so an interactive_stop that succeeded on + // the host but failed to persist `state = "stopped"` doesn't go + // silently — otherwise the row stays `"running"` forever and the + // sidebar badge / list view never recover. + if let Err(error) = persist_result { + tracing::warn!( + sid = %sid, + ?error, + "interactive_stop: DB state write failed; row may stay marked running until next reconcile" + ); + } + 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()) +} + +/// 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> { + interactive_list_for_workspace_inner(state.inner(), workspace_id).await +} + +/// Testable core of [`interactive_list_for_workspace`]. See that +/// function for the contract; the only difference is the borrowed +/// `&AppState` so tests can construct a state value directly. +pub(crate) async fn interactive_list_for_workspace_inner( + 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()) +} + +/// List the orphaned interactive sids currently pending cleanup. +/// Populated by the boot reconciler when the host reports +/// `claudette-` sessions the DB doesn't know about (see +/// [`crate::interactive_lifecycle`]). The frontend uses this for +/// reload-without-event recovery — the +/// `interactive://orphans-detected` event is fired once at boot, so a +/// page that mounts late can pull the list via this command instead +/// of waiting for the next reboot. +/// +/// Returns an empty list when there are no orphans pending; safe to +/// poll without side effects. +#[tauri::command] +pub async fn interactive_list_orphans(state: State<'_, AppState>) -> Result, String> { + interactive_list_orphans_inner(state.inner()).await +} + +/// Testable core of [`interactive_list_orphans`]. See that function +/// for the contract; the only difference is the borrowed `&AppState` +/// so tests can construct a state value directly. +pub(crate) async fn interactive_list_orphans_inner( + state: &AppState, +) -> Result, String> { + let map = state.interactive_orphans.read().await; + Ok(map.keys().cloned().collect()) +} + +/// Stop every orphan interactive session currently registered on +/// [`AppState::interactive_orphans`]. Each call to +/// `host.stop(sid, Graceful)` is best-effort: a failure is logged and +/// skipped so a single unreachable host can't poison the rest of the +/// batch. The DB is untouched (orphans are by definition not in the +/// DB). +/// +/// Returns the list of sids that were successfully stopped. Sids that +/// errored out are tracing-logged and dropped from the orphan map so +/// the frontend toast doesn't keep reappearing on repeated calls. +#[tauri::command] +pub async fn interactive_cleanup_orphans( + state: State<'_, AppState>, +) -> Result, String> { + interactive_cleanup_orphans_inner(state.inner()).await +} + +/// Testable core of [`interactive_cleanup_orphans`]. See that function +/// for the contract; the only difference is the borrowed `&AppState` +/// so tests can construct a state value directly. +pub(crate) async fn interactive_cleanup_orphans_inner( + state: &AppState, +) -> Result, String> { + // Drain the orphan map under a write lock so concurrent cleanup + // calls don't double-stop the same sid. + let drained: Vec<(String, Arc)> = { + let mut map = state.interactive_orphans.write().await; + map.drain().collect() + }; + let mut stopped: Vec = Vec::with_capacity(drained.len()); + for (sid, host) in drained { + match host.stop(&SessionId(sid.clone()), StopMode::Graceful).await { + Ok(()) => stopped.push(sid), + Err(err) => { + tracing::warn!( + target: "claudette::interactive", + sid = %sid, + error = %err, + "cleanup_orphans: host.stop failed; dropping from orphan map", + ); + } + } + } + Ok(stopped) +} + +/// 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 { + //! Branch coverage for the `interactive_*` Tauri command handlers. + //! + //! Each command is exercised through its `_inner` helper so the + //! tests can construct a plain `&AppState` (with an on-disk SQLite + //! file under a tempdir and pre-seeded `interactive_hosts` map) + //! without booting a real Tauri runtime. + //! + //! Test layout (one section per command): + //! 1. `interactive_start_inner` — happy path + flag-off guard. + //! 2. `interactive_send_input_inner` — happy path + missing-sid + + //! flag-off guard. + //! 3. `interactive_capture_screen_inner` — happy path (DB persists + //! the blob), flag-off, "no rows" tolerance pin. + //! 4. `interactive_stop_inner` — graceful + force, DB transition + //! to `"stopped"`, sid mapping removed. + //! 5. `interactive_list_for_workspace_inner` — populated + empty. + //! 6. `interactive_list_orphans_inner` + + //! `interactive_cleanup_orphans_inner` — drain + per-sid stop. + //! + //! The host-resolution-failure branch for `interactive_start_inner` + //! is intentionally not exercised: `interactive_host_for` only + //! returns `Err` when `select_default_host` fails, and the current + //! `select_default_host` always returns Ok (it falls back to + //! constructing a `SidecarHost` whose constructor is infallible). + //! Pre-seeding `interactive_hosts` is the only test path that does + //! NOT take the failure branch, so we cover that side and leave the + //! true-failure path uncovered — it is shallow (one `map_err` + + //! return) and the only failure mode in production is a host that + //! fails its own connectivity check, which happens later. + //! + //! `interactive_attach` is intentionally skipped because the body + //! is a one-liner that delegates to `spawn_attach_forwarder`, which + //! consumes a real `AppHandle::emit` directly and would require a + //! full Tauri runtime to exercise meaningfully. + + use super::*; + use async_trait::async_trait; + use claudette::agent::interactive_host::{ + AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, + }; + use claudette::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; + use claudette::db::{Database, InteractiveSessionRow}; + use claudette::model::{AgentStatus, Repository, Workspace, WorkspaceStatus}; + use claudette::plugin_runtime::PluginRegistry; + use std::path::PathBuf; + use std::sync::Mutex as StdMutex; + use std::sync::OnceLock; + use tempfile::TempDir; + use tokio::sync::Mutex as AsyncMutex; + + /// Process-global async mutex for tests that mutate + /// `$CLAUDETTE_HOME` or `$CLAUDETTE_CLI`. + /// `interactive_start_inner` reads both across `await` points + /// (`claudette::path::claudette_home()` and + /// `AppState::bundled_cli_binary_path()`), and the env is + /// process-wide, so two tests poking it at once would race. An + /// async mutex lets the guard live across the inner-fn `await` + /// without tripping clippy's `await_holding_lock` lint. + fn env_lock() -> &'static AsyncMutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| AsyncMutex::new(())) + } + + /// Spin up a fresh on-disk DB under a tempdir, run migrations, and + /// seed a placeholder repository so per-test workspace inserts + /// satisfy the FK. + fn make_db() -> (TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("claudette.db"); + let db = Database::open(&db_path).unwrap(); + db.insert_repository(&make_repo("repo-1", "/tmp/repo1", "repo-1")) + .unwrap(); + (tmp, db_path) + } + + fn make_app_state(db_path: PathBuf) -> AppState { + // Same shape as `tray.rs::fresh_state` / + // `interactive_lifecycle.rs::tests::make_app_state`. An empty + // plugin dir keeps registry construction allocation-only. + let plugins = PluginRegistry::discover(std::path::Path::new("/nonexistent")); + AppState::new(db_path, std::path::PathBuf::from("/tmp"), plugins) + } + + fn make_repo(id: &str, path: &str, name: &str) -> Repository { + Repository { + id: id.into(), + path: path.into(), + name: name.into(), + path_slug: name.into(), + icon: None, + created_at: String::new(), + setup_script: None, + custom_instructions: None, + sort_order: 0, + branch_rename_preferences: None, + setup_script_auto_run: false, + archive_script: None, + archive_script_auto_run: false, + base_branch: None, + default_remote: None, + required_inputs: None, + path_valid: true, + } + } + + fn make_workspace(id: &str, repo_id: &str, name: &str) -> Workspace { + Workspace { + id: id.into(), + repository_id: repo_id.into(), + name: name.into(), + branch_name: format!("claudette/{name}"), + worktree_path: None, + status: WorkspaceStatus::Active, + agent_status: AgentStatus::Idle, + status_line: String::new(), + created_at: String::new(), + sort_order: 0, + input_values: None, + } + } + + fn make_row(sid: &str, ws_id: &str, state: &str) -> InteractiveSessionRow { + InteractiveSessionRow { + sid: sid.into(), + workspace_id: ws_id.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, + } + } + + /// Recording fake host: every trait method records its parameters + /// (where useful) and returns canned data. Sessions are tracked as + /// a `Vec` so `status()` can echo what `ensure_session` + /// registered. + struct FakeInteractiveHost { + ensure_calls: StdMutex>, + send_calls: StdMutex>, + stop_calls: StdMutex>, + capture_calls: StdMutex>, + /// Bytes returned from `capture_screen`. Default `\x1b[31mhi\x1b[0m`. + capture_bytes: Vec, + /// Sessions reported by `status()`. + status_sessions: StdMutex>, + } + + impl FakeInteractiveHost { + fn new() -> Self { + Self { + ensure_calls: StdMutex::new(Vec::new()), + send_calls: StdMutex::new(Vec::new()), + stop_calls: StdMutex::new(Vec::new()), + capture_calls: StdMutex::new(Vec::new()), + capture_bytes: b"\x1b[31mhi\x1b[0m".to_vec(), + status_sessions: StdMutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl InteractiveHost for FakeInteractiveHost { + async fn ensure_session( + &self, + sid: &SessionId, + spec: &SessionSpec, + ) -> Result { + self.ensure_calls + .lock() + .unwrap() + .push((sid.clone(), spec.clone())); + self.status_sessions + .lock() + .unwrap() + .push(HostSessionSummary { + sid: sid.clone(), + pid: None, + running: true, + }); + Ok(HostHandle { + sid: sid.clone(), + pid: None, + rows: spec.rows, + cols: spec.cols, + }) + } + async fn attach(&self, _sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + unreachable!("D2 tests do not exercise attach") + } + async fn send_input( + &self, + sid: &SessionId, + payload: InputPayload, + ) -> Result<(), HostError> { + self.send_calls.lock().unwrap().push((sid.clone(), payload)); + Ok(()) + } + async fn capture_screen(&self, sid: &SessionId) -> Result { + self.capture_calls.lock().unwrap().push(sid.clone()); + Ok(ScreenSnapshot { + rows: 24, + cols: 80, + ansi_bytes: self.capture_bytes.clone(), + }) + } + async fn resize(&self, _sid: &SessionId, _rows: u16, _cols: u16) -> Result<(), HostError> { + Ok(()) + } + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + Ok(()) + } + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError> { + self.stop_calls.lock().unwrap().push((sid.clone(), mode)); + Ok(()) + } + async fn status(&self) -> Result { + Ok(HostStatus { + host_version: "fake".into(), + sessions: self.status_sessions.lock().unwrap().clone(), + }) + } + } + + /// Specialized fake for the orphan-cleanup test. Records every + /// `stop()` invocation so the test can assert the cleanup batch + /// reached the expected sids. + struct StopTrackingHost { + stop_calls: StdMutex>, + } + + impl StopTrackingHost { + fn new() -> Self { + Self { + stop_calls: StdMutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl InteractiveHost for StopTrackingHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unreachable!() + } + async fn attach(&self, _sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + unreachable!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unreachable!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unreachable!() + } + async fn resize(&self, _sid: &SessionId, _rows: u16, _cols: u16) -> Result<(), HostError> { + unreachable!() + } + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + unreachable!() + } + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError> { + self.stop_calls.lock().unwrap().push((sid.clone(), mode)); + Ok(()) + } + async fn status(&self) -> Result { + Ok(HostStatus { + host_version: "stop-tracking".into(), + sessions: vec![], + }) + } + } + + fn make_start_args(workspace_id: &str) -> StartInteractiveArgs { + StartInteractiveArgs { + workspace_id: workspace_id.into(), + working_dir: "/tmp/repo1".into(), + rows: 24, + cols: 80, + claude_binary: "claude".into(), + claude_args: vec!["--print".into()], + } + } + + fn null_hook_emitter() -> HookForwardEmitter { + Arc::new(|_topic: &str, _payload: &serde_json::Value| {}) + } + + // --- Existing pure-function tests --------------------------------- + + #[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"); + } + + // --- interactive_start_inner -------------------------------------- + + /// Step 1: happy path. With the flag ON, the bundled CLI sidecar + /// resolvable, and a pre-seeded fake host, `interactive_start_inner` + /// should: + /// - call `ensure_session` on the host exactly once, + /// - persist an `interactive_sessions` row in state `"running"` + /// with the synthesized sid + workspace_id + claude_args JSON, + /// - register the sid in the sid→workspace_id reverse index. + #[tokio::test] + async fn interactive_start_happy_path_persists_row_and_registers_sid() { + let _env_guard = env_lock().lock().await; + let home_tmp = tempfile::tempdir().unwrap(); + let cli_tmp = tempfile::NamedTempFile::new().unwrap(); + // SAFETY: env mutation is serialized via `env_lock()` so no + // other thread is reading these vars while we set them. + unsafe { + std::env::set_var("CLAUDETTE_HOME", home_tmp.path()); + std::env::set_var("CLAUDETTE_CLI", cli_tmp.path()); + } + + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + + let state = make_app_state(db_path.clone()); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + + let result = interactive_start_inner(&state, make_start_args("ws-1"), null_hook_emitter()) + .await + .expect("happy path must succeed"); + + // Host saw exactly one ensure_session call for the synthesized sid. + // Lock inside a scope so the std MutexGuard drops before any + // subsequent `.await` (clippy's `await_holding_lock`). + { + let ensure_calls = host.ensure_calls.lock().unwrap(); + assert_eq!( + ensure_calls.len(), + 1, + "ensure_session must be called exactly once" + ); + assert_eq!(ensure_calls[0].0.as_str(), result.sid); + // The spec's claude args + working dir round-trip through the call. + assert_eq!(ensure_calls[0].1.working_dir, "/tmp/repo1"); + assert_eq!(ensure_calls[0].1.claude_args, vec!["--print".to_string()]); + } + + // Sid format: `claudette--<8 hex chars>`. + assert!( + result.sid.starts_with("claudette-ws-1-"), + "sid should be claudette--, got: {}", + result.sid + ); + + // DB row persisted with the expected shape. + let row = db.get_interactive_session(&result.sid).unwrap().unwrap(); + assert_eq!(row.workspace_id, "ws-1"); + assert_eq!(row.state, "running"); + assert_eq!(row.claude_flags_json, "[\"--print\"]"); + assert!(row.crash_reason.is_none()); + + // sid→workspace_id reverse index populated. + let reverse = state.interactive_sessions.read().await; + assert_eq!( + reverse.get(&result.sid).map(String::as_str), + Some("ws-1"), + "reverse index must map the new sid to its workspace", + ); + drop(reverse); + + // Cleanup env mutations. + unsafe { + std::env::remove_var("CLAUDETTE_HOME"); + std::env::remove_var("CLAUDETTE_CLI"); + } + } + + /// Step 2: flag OFF — even with a pre-seeded host, the command + /// must short-circuit with `"Claude Interactive is disabled"` and + /// must NOT call `ensure_session`, register the sid, or touch the + /// DB. + #[tokio::test] + async fn interactive_start_flag_off_returns_disabled_error() { + let _env_guard = env_lock().lock().await; + let (_db_tmp, db_path) = make_db(); + // Flag is left unset (defaults to disabled). + let state = make_app_state(db_path.clone()); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + + let err = interactive_start_inner(&state, make_start_args("ws-1"), null_hook_emitter()) + .await + .expect_err("flag-OFF must error"); + assert_eq!( + err, "Claude Interactive is disabled", + "flag-OFF must return the canonical disabled string verbatim", + ); + + assert!( + host.ensure_calls.lock().unwrap().is_empty(), + "ensure_session must not be called when the flag is off", + ); + assert!(state.interactive_sessions.read().await.is_empty()); + } + + /// Step 3 deviation: the spec calls for a host-resolution failure + /// path, but `interactive_host_for` only fails when + /// `select_default_host` fails, which it never does in practice. + /// Instead we pin the "missing CLI binary" branch — the closest + /// reachable failure between host resolution and host-call — + /// since it is the next exit point and the only failure path that + /// can be reached without a fault-injection hook into the host + /// layer. + #[tokio::test] + async fn interactive_start_returns_error_when_cli_binary_missing() { + let _env_guard = env_lock().lock().await; + let home_tmp = tempfile::tempdir().unwrap(); + // Force `bundled_cli_binary_path` to fail by pointing + // `CLAUDETTE_CLI` at a guaranteed-nonexistent path that is + // unique to this test run. This bypasses the + // current_exe + dev-fallback resolution entirely and pins the + // missing-CLI error branch on every runner — including CI + // machines that happen to have a staged sidecar on disk. + // SAFETY: env mutation serialized via `env_lock()`. + let bogus_cli = format!( + "/nonexistent/path/claudette-cli-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + ); + unsafe { + std::env::set_var("CLAUDETTE_HOME", home_tmp.path()); + std::env::set_var("CLAUDETTE_CLI", &bogus_cli); + } + + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + let state = make_app_state(db_path); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + + let err = interactive_start_inner(&state, make_start_args("ws-1"), null_hook_emitter()) + .await + .expect_err("missing CLI must surface as Err"); + assert_eq!( + err, "claudette-cli binary not found", + "missing CLI binary must surface the canonical error string", + ); + assert!( + host.ensure_calls.lock().unwrap().is_empty(), + "ensure_session must not be called when the CLI binary is missing", + ); + + unsafe { + std::env::remove_var("CLAUDETTE_HOME"); + std::env::remove_var("CLAUDETTE_CLI"); + } + } + + // --- interactive_send_input_inner --------------------------------- + + /// Step 4 happy path: with the flag ON and a registered sid, the + /// host receives a `SendInput::Text` payload carrying the exact + /// bytes passed in. + #[tokio::test] + async fn interactive_send_input_forwards_text_to_host() { + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + let state = make_app_state(db_path); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + let sid = "claudette-ws1-aaaaaaaa"; + state.register_interactive_session(sid, "ws-1".into()).await; + + interactive_send_input_inner(&state, sid.into(), "hello\n".into()) + .await + .expect("send_input happy path"); + + let calls = host.send_calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "exactly one send_input call"); + assert_eq!(calls[0].0.as_str(), sid); + match &calls[0].1 { + InputPayload::Text { text } => assert_eq!(text, "hello\n"), + other => panic!("expected Text payload, got {other:?}"), + } + } + + /// Step 4 missing-sid: an unknown sid surfaces a + /// `"interactive session not found: "` error (matches the + /// production format string so the frontend can surface the sid + /// for diagnostics). + #[tokio::test] + async fn interactive_send_input_returns_not_found_for_unknown_sid() { + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + let state = make_app_state(db_path); + + let err = interactive_send_input_inner(&state, "no-such-sid".into(), "noop".into()) + .await + .expect_err("missing sid must error"); + assert_eq!(err, "interactive session not found: no-such-sid"); + } + + /// Step 4 flag-OFF: even with a valid registered sid, the command + /// short-circuits to the disabled error. + #[tokio::test] + async fn interactive_send_input_flag_off_returns_disabled_error() { + let (_db_tmp, db_path) = make_db(); + let state = make_app_state(db_path); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + let sid = "claudette-ws1-bbbbbbbb"; + state.register_interactive_session(sid, "ws-1".into()).await; + + let err = interactive_send_input_inner(&state, sid.into(), "ignored".into()) + .await + .expect_err("flag-OFF must error"); + assert_eq!(err, "Claude Interactive is disabled"); + assert!(host.send_calls.lock().unwrap().is_empty()); + } + + // --- interactive_capture_screen_inner ----------------------------- + + /// Step 5 happy path: capture returns the base64 of the host's + /// `ansi_bytes`, AND the DB row's `last_screen_blob` is updated + /// with the same raw bytes. + #[tokio::test] + async fn interactive_capture_screen_persists_blob_and_returns_base64() { + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + let sid = "claudette-ws1-cccccccc"; + // Seed the row so `update_interactive_session_screen` finds it. + db.create_interactive_session(&make_row(sid, "ws-1", "running")) + .unwrap(); + + let state = make_app_state(db_path); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + state.register_interactive_session(sid, "ws-1".into()).await; + + let b64 = interactive_capture_screen_inner(&state, sid.into()) + .await + .expect("capture_screen happy path"); + + // The base64-encoded payload is the host bytes, base64'd. + let decoded = base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .expect("returned value must be valid base64"); + assert_eq!(decoded.as_slice(), b"\x1b[31mhi\x1b[0m"); + + // Same bytes landed in the DB row. + let row = db.get_interactive_session(sid).unwrap().unwrap(); + assert_eq!( + row.last_screen_blob.as_deref(), + Some(b"\x1b[31mhi\x1b[0m".as_slice()), + "DB persist must mirror the host's ansi_bytes", + ); + assert!(row.last_attached_at.is_some(), "last_attached_at stamped"); + assert_eq!(host.capture_calls.lock().unwrap().len(), 1); + } + + /// Step 5 missing-row tolerance: when no DB row exists for the + /// sid, the underlying CRUD layer returns `QueryReturnedNoRows`, + /// and the command swallows that specific error so the capture + /// itself still succeeds. This pins the user-visible contract: + /// the command does NOT propagate "no rows" as an error. + #[tokio::test] + async fn interactive_capture_screen_tolerates_missing_db_row() { + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + // No interactive_sessions row inserted: the UPDATE will hit + // zero rows and surface QueryReturnedNoRows, which the command + // is expected to ignore. + let state = make_app_state(db_path); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + let sid = "claudette-ws1-dddddddd"; + state.register_interactive_session(sid, "ws-1".into()).await; + + let b64 = interactive_capture_screen_inner(&state, sid.into()) + .await + .expect("missing-row case must still return the base64 blob"); + // Decoding succeeds — the host produced bytes even though + // persistence was a no-op. + let _ = base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .expect("base64 still valid"); + } + + /// Step 5 flag-OFF: the command short-circuits to the disabled + /// error without touching the host. + #[tokio::test] + async fn interactive_capture_screen_flag_off_returns_disabled_error() { + let (_db_tmp, db_path) = make_db(); + let state = make_app_state(db_path); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + let sid = "claudette-ws1-eeeeeeee"; + state.register_interactive_session(sid, "ws-1".into()).await; + + let err = interactive_capture_screen_inner(&state, sid.into()) + .await + .expect_err("flag-OFF must error"); + assert_eq!(err, "Claude Interactive is disabled"); + assert!(host.capture_calls.lock().unwrap().is_empty()); + } + + // --- interactive_stop_inner --------------------------------------- + + /// Step 6 graceful: `force=false` maps to `StopMode::Graceful`, + /// the DB row transitions to `"stopped"`, AND the sid mapping is + /// removed. + #[tokio::test] + async fn interactive_stop_graceful_marks_row_stopped_and_drops_sid() { + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + let sid = "claudette-ws1-ffffffff"; + db.create_interactive_session(&make_row(sid, "ws-1", "running")) + .unwrap(); + + let state = make_app_state(db_path); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + state.register_interactive_session(sid, "ws-1".into()).await; + + interactive_stop_inner(&state, sid.into(), false) + .await + .expect("graceful stop"); + + // Scope the std MutexGuard so it drops before the later `.await`. + { + let calls = host.stop_calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "exactly one host.stop call"); + assert!( + matches!(calls[0].1, StopMode::Graceful), + "force=false must map to Graceful" + ); + } + + let row = db.get_interactive_session(sid).unwrap().unwrap(); + assert_eq!(row.state, "stopped", "DB row must move to stopped"); + assert!( + !state.interactive_sessions.read().await.contains_key(sid), + "sid→workspace_id mapping must be dropped", + ); + } + + /// Step 6 force: `force=true` maps to `StopMode::Force`. Same DB + /// transition and sid-cleanup invariants as the graceful path. + #[tokio::test] + async fn interactive_stop_force_uses_force_stop_mode() { + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + let sid = "claudette-ws1-99999999"; + db.create_interactive_session(&make_row(sid, "ws-1", "running")) + .unwrap(); + + let state = make_app_state(db_path); + let host = Arc::new(FakeInteractiveHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + state.register_interactive_session(sid, "ws-1".into()).await; + + interactive_stop_inner(&state, sid.into(), true) + .await + .expect("force stop"); + + // Scope the std MutexGuard so it drops before the later `.await`. + { + let calls = host.stop_calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!(matches!(calls[0].1, StopMode::Force)); + } + + let row = db.get_interactive_session(sid).unwrap().unwrap(); + assert_eq!(row.state, "stopped"); + assert!(!state.interactive_sessions.read().await.contains_key(sid)); + } + + /// Step 6 flag-OFF + missing-sid: cover the early exits. + #[tokio::test] + async fn interactive_stop_flag_off_returns_disabled_error() { + let (_db_tmp, db_path) = make_db(); + let state = make_app_state(db_path); + let err = interactive_stop_inner(&state, "anything".into(), false) + .await + .expect_err("flag-OFF must error"); + assert_eq!(err, "Claude Interactive is disabled"); + } + + #[tokio::test] + async fn interactive_stop_missing_sid_returns_not_found() { + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + let state = make_app_state(db_path); + let err = interactive_stop_inner(&state, "no-such-sid".into(), false) + .await + .expect_err("missing sid must error"); + assert_eq!(err, "interactive session not found: no-such-sid"); + } + + // --- interactive_list_for_workspace_inner ------------------------- + + /// Step 7: a populated workspace returns its rows newest-first, + /// and the `InteractiveSessionRow` → `InteractiveSessionListItem` + /// shape conversion preserves every field. + #[tokio::test] + async fn interactive_list_for_workspace_returns_rows_for_workspace() { + let (_db_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + db.insert_workspace(&make_workspace("ws-2", "repo-1", "other")) + .unwrap(); + + // Two rows for ws-1, one for ws-2. + let mut older = make_row("claudette-ws1-older111", "ws-1", "detached"); + older.created_at = "2026-05-15T00:00:00Z".into(); + let mut newer = make_row("claudette-ws1-newer222", "ws-1", "running"); + newer.created_at = "2026-05-16T00:00:00Z".into(); + let other = make_row("claudette-ws2-only333", "ws-2", "running"); + db.create_interactive_session(&older).unwrap(); + db.create_interactive_session(&newer).unwrap(); + db.create_interactive_session(&other).unwrap(); + + let state = make_app_state(db_path); + let rows = interactive_list_for_workspace_inner(&state, "ws-1".into()) + .await + .expect("list ws-1"); + assert_eq!(rows.len(), 2, "only ws-1 sessions returned"); + // DESC by created_at: newer first. + assert_eq!(rows[0].sid, "claudette-ws1-newer222"); + assert_eq!(rows[0].state, "running"); + assert_eq!(rows[1].sid, "claudette-ws1-older111"); + assert_eq!(rows[1].state, "detached"); + for r in &rows { + assert_eq!(r.workspace_id, "ws-1"); + } + } + + /// Step 7 empty case: an unknown / empty workspace returns an + /// empty Vec (not an error). + #[tokio::test] + async fn interactive_list_for_workspace_returns_empty_for_unknown_workspace() { + let (_db_tmp, db_path) = make_db(); + let state = make_app_state(db_path); + let rows = interactive_list_for_workspace_inner(&state, "nonexistent".into()) + .await + .expect("empty workspace must be Ok([])"); + assert!(rows.is_empty()); + } + + // --- interactive_list_orphans_inner + cleanup_orphans_inner ------- + + /// Step 8 list: returns every sid currently in the orphans map. + #[tokio::test] + async fn interactive_list_orphans_returns_sids_from_state() { + let (_db_tmp, db_path) = make_db(); + let state = make_app_state(db_path); + // Pre-seed two orphan entries. + let host: Arc = Arc::new(StopTrackingHost::new()); + { + let mut map = state.interactive_orphans.write().await; + map.insert("claudette-x-orphan1".to_string(), Arc::clone(&host)); + map.insert("claudette-x-orphan2".to_string(), Arc::clone(&host)); + } + + let mut listed = interactive_list_orphans_inner(&state) + .await + .expect("list orphans must be Ok"); + listed.sort(); + assert_eq!( + listed, + vec![ + "claudette-x-orphan1".to_string(), + "claudette-x-orphan2".to_string() + ], + ); + } + + /// Step 8 cleanup: drains every orphan, calls `host.stop` once per + /// sid with `Graceful`, and clears the orphans map. + #[tokio::test] + async fn interactive_cleanup_orphans_stops_each_and_drains_map() { + let (_db_tmp, db_path) = make_db(); + let state = make_app_state(db_path); + let host = Arc::new(StopTrackingHost::new()); + let host_dyn: Arc = Arc::clone(&host) as _; + { + let mut map = state.interactive_orphans.write().await; + map.insert("claudette-x-orphan1".to_string(), Arc::clone(&host_dyn)); + map.insert("claudette-x-orphan2".to_string(), Arc::clone(&host_dyn)); + } + + let mut stopped = interactive_cleanup_orphans_inner(&state) + .await + .expect("cleanup must be Ok"); + stopped.sort(); + assert_eq!( + stopped, + vec![ + "claudette-x-orphan1".to_string(), + "claudette-x-orphan2".to_string() + ], + ); + + // Both stops landed on the host, both with StopMode::Graceful. + // Scope the std MutexGuard so it drops before the later `.await`. + { + let calls = host.stop_calls.lock().unwrap(); + assert_eq!(calls.len(), 2, "one stop per orphan"); + for (_, mode) in calls.iter() { + assert!(matches!(mode, StopMode::Graceful)); + } + } + + // Orphans map is fully drained. + assert!(state.interactive_orphans.read().await.is_empty()); + } + + /// Step 8 cleanup empty: a no-op on an empty map returns an empty + /// Vec and doesn't error. + #[tokio::test] + async fn interactive_cleanup_orphans_empty_map_returns_empty_vec() { + let (_db_tmp, db_path) = make_db(); + let state = make_app_state(db_path); + let stopped = interactive_cleanup_orphans_inner(&state) + .await + .expect("cleanup on empty map must be Ok"); + assert!(stopped.is_empty()); + } +} 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/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index b2b1ef8d3..1904e597c 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -751,6 +751,18 @@ pub(crate) async fn archive_workspace_inner( let _ = db.end_agent_session(sid, true); } + // Gracefully stop any interactive (long-lived `claude` TUI) sessions + // belonging to this workspace BEFORE we touch the workspace row. The + // `interactive_sessions` table has `ON DELETE CASCADE` against + // `workspaces(id)`, so the hard-delete path below would otherwise + // remove the rows while the tmux/sidecar host still has live + // sessions — orphaning them with no Claudette tracking. + // + // Failures inside `stop_sessions_for_workspace` are logged and + // skipped so a misbehaving host can't block archive. The DB + // cascade still runs and cleans up the rows. + stop_interactive_sessions_for_workspace_teardown(state, &state.db_path, id).await; + // Resolve and run the archive script before removing the worktree so it // still has filesystem access. Best-effort: failure logs to chat but // does not block the archive. The frontend gates this via @@ -1027,6 +1039,13 @@ pub async fn delete_workspace( let _ = claudette::agent::stop_agent(pid).await; } + // Gracefully stop any interactive (long-lived `claude` TUI) sessions + // belonging to this workspace BEFORE the `delete_workspace_with_summary` + // call below cascades through `interactive_sessions`. Doing this + // after the cascade would leave orphan tmux / sidecar sessions + // running with no Claudette row tracking them. + stop_interactive_sessions_for_workspace_teardown(&state, &state.db_path, &id).await; + if let Some(repo) = repo { // Remove worktree if active. if let Some(ref wt_path) = ws.worktree_path { @@ -1487,6 +1506,93 @@ async fn delete_workspaces_bulk_body( }) } +/// Shared interactive-session teardown used by both the archive and +/// delete paths. Enumerates the persisted `interactive_sessions` rows +/// for `workspace_id`, resolves the workspace's `InteractiveHost`, and +/// calls `host.stop(sid, Graceful)` on each live row through the +/// lib-level [`claudette::interactive::stop_sessions_for_workspace`] +/// helper. Also drops the sid → workspace_id entries in +/// [`AppState::interactive_sessions`] so a stale lookup can't route a +/// later command to a torn-down host. +/// +/// `Database` is `!Sync` (rusqlite's `Connection`), so this helper +/// takes `db_path` and opens its own short-lived connection scoped to +/// the row enumeration. That connection is dropped before the first +/// `.await` on the host, keeping the resulting future `Send` and safe +/// to call from a Tauri command body. +/// +/// All errors are logged and swallowed: the user-facing operation +/// (archive / delete) must still complete. If we can't read the rows, +/// or the host can't be resolved, the FK `ON DELETE CASCADE` will +/// still clean up the DB rows once the workspace is gone — the worst +/// case is an orphan host session, not a wedged workspace teardown. +async fn stop_interactive_sessions_for_workspace_teardown( + state: &AppState, + db_path: &Path, + workspace_id: &str, +) { + // Scope the !Sync Database to a synchronous block so it's dropped + // before any await below. Opening a fresh connection here mirrors + // the pattern every Tauri command uses for the same reason. + let rows: Vec = { + let db = match Database::open(db_path) { + Ok(db) => db, + Err(err) => { + tracing::warn!( + target: "claudette::workspace", + %workspace_id, + error = %err, + "failed to open db for interactive-session teardown; skipping host.stop", + ); + return; + } + }; + match db.list_interactive_sessions_for_workspace(workspace_id) { + Ok(rows) => rows, + Err(err) => { + tracing::warn!( + target: "claudette::workspace", + %workspace_id, + error = %err, + "failed to enumerate interactive sessions for teardown; skipping host.stop", + ); + return; + } + } + }; + + if rows.is_empty() { + // Fast path: no rows means no host resolution (which can + // spawn the sidecar binary). Mirrors the boot reconciler's + // empty-rows fast path. + return; + } + + match state.interactive_host_for(workspace_id).await { + Ok(host) => { + claudette::interactive::stop_sessions_for_workspace(&rows, host.as_ref()).await; + } + Err(err) => { + tracing::warn!( + target: "claudette::workspace", + %workspace_id, + rows = rows.len(), + error = %err, + "could not resolve interactive host for teardown; letting cascade clean up rows", + ); + } + } + + // Drop sid → workspace_id mappings regardless of stop outcome so a + // later command can't route through them to a now-dead host. The + // workspace-keyed host cache in `interactive_hosts` is intentionally + // NOT evicted here — it's cheap to keep, and a sibling workspace + // sharing a host (future v2) would still need it. + for row in &rows { + state.unregister_interactive_session(&row.sid).await; + } +} + #[tauri::command] #[tracing::instrument( target = "claudette::workspace", @@ -2448,4 +2554,397 @@ mod tests { assert!(!wrote); assert!(session.pending_history_prelude.is_none()); } + + // ----- Interactive-session teardown on archive + delete ------------- + // + // The Tauri-side `archive_workspace_inner` and `delete_workspace` + // both route interactive-session shutdown through + // `stop_interactive_sessions_for_workspace_teardown`. The helper + // lives in this module (private) and is the single seam between + // workspace teardown and the `InteractiveHost` trait, so these + // tests exercise it directly. The shared lib-level + // `claudette::interactive::stop_sessions_for_workspace` already has + // its own coverage in `src/interactive.rs::tests`; this block adds + // the *Tauri-side* contract: + // + // 1. The helper, when called with a workspace that has live + // `interactive_sessions` rows, resolves the cached host and + // invokes `host.stop(sid, Graceful)` for each row (Step 2 in + // the plan — what `delete_workspace` / `archive_workspace_inner` + // rely on). + // 2. The helper also drops the sid → workspace_id entries from + // `AppState::interactive_sessions` so a later command can't + // route a stale lookup to a now-dead host. + // 3. After the workspace row goes away, the FK `ON DELETE + // CASCADE` on `interactive_sessions.workspace_id` actually + // fires and removes the rows (Step 3 in the plan — proves the + // "cascade fired" half of the delete contract). + // 4. The archive op leaves the workspace row in place but the + // helper is the sole owner of host-side teardown, so calling + // the helper still stops the host sessions even though the DB + // rows survive (Step 4 in the plan). + + use crate::state::AppState; + use async_trait::async_trait; + use claudette::agent::interactive_host::{ + AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, + }; + use claudette::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; + use claudette::db::{Database, InteractiveSessionRow}; + use claudette::model::{AgentStatus, Repository, Workspace, WorkspaceStatus}; + use claudette::plugin_runtime::PluginRegistry; + use std::path::PathBuf; + use std::sync::Arc; + use std::sync::Mutex as StdMutex; + use tempfile::TempDir; + + /// Build a fresh on-disk DB under a tempdir and seed a repository + /// row so the FK on `workspaces.repository_id` is satisfied when + /// each test inserts its workspace. + fn make_db() -> (TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("claudette.db"); + let db = Database::open(&db_path).unwrap(); + db.insert_repository(&Repository { + id: "repo-1".into(), + path: "/tmp/repo1".into(), + name: "repo-1".into(), + path_slug: "repo-1".into(), + icon: None, + created_at: String::new(), + setup_script: None, + custom_instructions: None, + sort_order: 0, + branch_rename_preferences: None, + setup_script_auto_run: false, + archive_script: None, + archive_script_auto_run: false, + base_branch: None, + default_remote: None, + required_inputs: None, + path_valid: true, + }) + .unwrap(); + (tmp, db_path) + } + + fn make_app_state(db_path: PathBuf) -> AppState { + let plugins = PluginRegistry::discover(std::path::Path::new("/nonexistent")); + AppState::new(db_path, std::path::PathBuf::from("/tmp"), plugins) + } + + fn make_workspace(id: &str, repo_id: &str, name: &str) -> Workspace { + Workspace { + id: id.into(), + repository_id: repo_id.into(), + name: name.into(), + branch_name: format!("claudette/{name}"), + worktree_path: None, + status: WorkspaceStatus::Active, + agent_status: AgentStatus::Idle, + status_line: String::new(), + created_at: String::new(), + sort_order: 0, + input_values: None, + } + } + + fn make_row(sid: &str, ws_id: &str, state: &str) -> InteractiveSessionRow { + InteractiveSessionRow { + sid: sid.into(), + workspace_id: ws_id.into(), + host_kind: "tmux".into(), + state: state.into(), + crash_reason: None, + created_at: "2026-05-18T00:00:00Z".into(), + last_attached_at: None, + last_screen_blob: None, + claude_flags_json: "[]".into(), + pid: None, + } + } + + /// Mock `InteractiveHost` that records every `stop(sid, mode)` + /// invocation and panics on any other method — workspace teardown + /// should only ever call `stop`. + struct StopTrackingHost { + stops: StdMutex>, + } + + impl StopTrackingHost { + fn new() -> Self { + Self { + stops: StdMutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl InteractiveHost for StopTrackingHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unreachable!("workspace teardown must not call ensure_session") + } + async fn attach(&self, _sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + unreachable!("workspace teardown must not call attach") + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unreachable!("workspace teardown must not call send_input") + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unreachable!("workspace teardown must not call capture_screen") + } + async fn resize(&self, _sid: &SessionId, _rows: u16, _cols: u16) -> Result<(), HostError> { + unreachable!("workspace teardown must not call resize") + } + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + unreachable!("workspace teardown must not call detach") + } + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError> { + self.stops.lock().unwrap().push((sid.0.clone(), mode)); + Ok(()) + } + async fn status(&self) -> Result { + Ok(HostStatus { + host_version: "stop-tracking".into(), + sessions: Vec::::new(), + }) + } + } + + /// Step 2 (delete path) + Step 4 (archive path) — the shared + /// teardown helper resolves the cached host and calls + /// `host.stop(sid, Graceful)` for each live interactive_sessions + /// row belonging to the workspace, and clears the + /// sid → workspace_id mappings. This is the common contract both + /// `delete_workspace` and `archive_workspace_inner` depend on; the + /// "live" tests below (cascade + archive-row-survival) layer on + /// the DB-side assertions. + #[tokio::test] + async fn teardown_helper_stops_each_live_session_and_clears_sid_map() { + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + // Two live rows in the target workspace and one row in a + // sibling workspace — the helper must NOT touch the sibling. + db.insert_workspace(&make_workspace("ws-other", "repo-1", "other")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-ws1-aaaaaaaa", "ws-1", "running")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-ws1-bbbbbbbb", "ws-1", "detached")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-wso-cccccccc", "ws-other", "running")) + .unwrap(); + + let state = make_app_state(db_path.clone()); + let host = Arc::new(StopTrackingHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + // Seed the sid → workspace mapping so we can pin the + // helper's "unregister_interactive_session" sweep. + state + .register_interactive_session("claudette-ws1-aaaaaaaa", "ws-1".into()) + .await; + state + .register_interactive_session("claudette-ws1-bbbbbbbb", "ws-1".into()) + .await; + state + .register_interactive_session("claudette-wso-cccccccc", "ws-other".into()) + .await; + + super::stop_interactive_sessions_for_workspace_teardown(&state, &db_path, "ws-1").await; + + // host.stop fired once per live ws-1 row, both with Graceful. + { + let stops = host.stops.lock().unwrap(); + assert_eq!( + stops.len(), + 2, + "host.stop must run once per live ws-1 row, got {stops:?}", + ); + for (_, mode) in stops.iter() { + assert!( + matches!(mode, StopMode::Graceful), + "teardown must use Graceful stop mode, got {mode:?}", + ); + } + let stopped_sids: Vec = stops.iter().map(|(s, _)| s.clone()).collect(); + assert!(stopped_sids.contains(&"claudette-ws1-aaaaaaaa".into())); + assert!(stopped_sids.contains(&"claudette-ws1-bbbbbbbb".into())); + assert!( + !stopped_sids.contains(&"claudette-wso-cccccccc".into()), + "sibling workspace's session must not be stopped", + ); + } + + // sid → workspace_id mappings for the torn-down workspace are + // gone; the sibling's mapping survives so its still-live + // session keeps routing. + let sessions = state.interactive_sessions.read().await; + assert!( + !sessions.contains_key("claudette-ws1-aaaaaaaa"), + "ws-1 sid mapping must be cleared after teardown", + ); + assert!( + !sessions.contains_key("claudette-ws1-bbbbbbbb"), + "ws-1 sid mapping must be cleared after teardown", + ); + assert_eq!( + sessions.get("claudette-wso-cccccccc"), + Some(&"ws-other".to_string()), + "sibling workspace's sid mapping must survive teardown", + ); + } + + /// Step 3 — after `delete_workspace_with_summary` runs (the SQL + /// that backs `delete_workspace`'s body), the FK `ON DELETE + /// CASCADE` on `interactive_sessions.workspace_id` actually fires + /// and removes the rows. Together with the helper test above this + /// pins the delete path's full contract: the host sees `stop` for + /// each live sid, and the DB rows are gone after the workspace + /// row is deleted. + #[tokio::test] + async fn delete_workspace_with_summary_cascades_interactive_sessions() { + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-ws1-aaaaaaaa", "ws-1", "running")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-ws1-bbbbbbbb", "ws-1", "detached")) + .unwrap(); + + // Sanity: both rows present before delete. + let pre = db + .list_interactive_sessions_for_workspace("ws-1") + .expect("list before delete"); + assert_eq!(pre.len(), 2, "two rows seeded for ws-1"); + + // Drive the same DB call the production `delete_workspace` + // body runs (after the teardown helper). We assert the DB + // shape because that's the "cascade fired" half of Step 3; + // the host-side teardown is covered by the helper test + // above. + db.delete_workspace_with_summary("ws-1") + .expect("delete_workspace_with_summary"); + + let post = db + .list_interactive_sessions_for_workspace("ws-1") + .expect("list after delete"); + assert!( + post.is_empty(), + "FK ON DELETE CASCADE must drop all interactive_sessions rows for the deleted workspace; got {post:?}", + ); + } + + /// Step 4 — archive does NOT remove the workspace row, so the FK + /// cascade does not fire. The teardown helper is the only thing + /// that brings host-side sessions down on the archive path; this + /// pins that contract: after archive_workspace_inner's helper + /// call, the host saw stop() for each session, even though the + /// DB rows would still be present until a later hard-delete. + #[tokio::test] + async fn teardown_helper_for_archive_path_stops_host_without_relying_on_cascade() { + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-ws1-aaaaaaaa", "ws-1", "running")) + .unwrap(); + + let state = make_app_state(db_path.clone()); + let host = Arc::new(StopTrackingHost::new()); + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::clone(&host) as _); + state + .register_interactive_session("claudette-ws1-aaaaaaaa", "ws-1".into()) + .await; + + // Archive path: the helper runs BEFORE + // `ops_workspace::archive` flips the row to Archived. We call + // the helper directly (the archive op is covered by its own + // tests) and then prove the DB row is intact — the helper's + // job is host-side teardown, not row mutation. + super::stop_interactive_sessions_for_workspace_teardown(&state, &db_path, "ws-1").await; + + { + let stops = host.stops.lock().unwrap(); + assert_eq!( + stops.len(), + 1, + "archive teardown must stop the one live interactive session, got {stops:?}", + ); + assert_eq!(stops[0].0, "claudette-ws1-aaaaaaaa"); + assert!(matches!(stops[0].1, StopMode::Graceful)); + } + + // Archive does not delete `interactive_sessions` rows on its + // own; the helper purely tears down the host side. The DB + // row therefore survives until a later hard-delete (which + // would re-run the cascade tested above). This pins the + // separation of responsibilities. + let rows = db + .list_interactive_sessions_for_workspace("ws-1") + .expect("list after archive teardown"); + assert_eq!( + rows.len(), + 1, + "archive teardown must leave DB rows in place for the cascade to handle later", + ); + // sid mapping was still cleared so a stale command can't + // route through a torn-down host. + assert!( + !state + .interactive_sessions + .read() + .await + .contains_key("claudette-ws1-aaaaaaaa"), + "sid mapping must be cleared on archive path too", + ); + } + + /// Empty-rows fast path — when no interactive_sessions rows + /// exist for the workspace, the helper skips host resolution + /// entirely (no `interactive_host_for` call, which would + /// otherwise spawn a sidecar / probe tmux). This is the + /// production-safety pin for the "workspace with no Claude + /// (Interactive) sessions" case, which is the overwhelmingly + /// common path for delete/archive today. + #[tokio::test] + async fn teardown_helper_skips_host_resolution_when_no_rows() { + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + // No interactive_sessions rows seeded. + + let state = make_app_state(db_path.clone()); + // Intentionally DO NOT seed `interactive_hosts`. If the + // helper tried to resolve a host it would fall into + // `select_default_host` and spawn the sidecar binary, which + // would either hang or fail under the test runtime. The fact + // that this test completes proves the empty-rows fast path + // returns before host resolution. + + super::stop_interactive_sessions_for_workspace_teardown(&state, &db_path, "ws-1").await; + + // No mappings to clear either; just assert state is sane. + assert!(state.interactive_sessions.read().await.is_empty()); + assert!(state.interactive_hosts.read().await.is_empty()); + } } diff --git a/src-tauri/src/interactive_lifecycle.rs b/src-tauri/src/interactive_lifecycle.rs new file mode 100644 index 000000000..168ad6518 --- /dev/null +++ b/src-tauri/src/interactive_lifecycle.rs @@ -0,0 +1,916 @@ +//! Tauri-side glue for the interactive-session boot reconciler. +//! +//! Wraps [`claudette::interactive::reattach_rows`] in a boot-safe +//! shape: groups the persisted `running` rows by workspace, resolves +//! the cached [`InteractiveHost`](claudette::agent::interactive_host::InteractiveHost) +//! through [`AppState::interactive_host_for`], and runs the reconciler +//! against that host. Failures (DB unreadable, host unavailable, +//! status RPC errored) are logged and isolated per workspace so a +//! single bad row can't wedge the rest of the boot path. +//! +//! The matching lib-level functions live in `claudette::interactive` +//! and are the testable units; this file is the thin "wire it into +//! the AppState" layer. +//! +//! Threading note: `claudette::interactive::reattach_rows` borrows a +//! `claudette::db::Database` across an `await` on `host.status()`, +//! and `Database` wraps a `!Sync` `rusqlite::Connection`. The +//! resulting future is therefore not `Send`, so we cannot park it +//! directly on a multi-thread Tokio runtime via +//! `tauri::async_runtime::spawn`. Instead, this module spawns a +//! single blocking thread, builds a `current_thread` Tokio runtime +//! there, and drives the reconciler for every workspace inside that +//! runtime. Crucially, the DB connection is opened ONCE inside that +//! blocking thread and reused across workspaces — opening per +//! workspace would risk concurrent `Database::open` calls fighting +//! for the SQLite OS-level lock and surfacing as `SQLITE_BUSY` on +//! non-WAL databases. +//! +//! Each per-workspace host is resolved up-front on the async caller +//! (because `interactive_host_for` is `async` and may need to spawn +//! the sidecar). The resolved `(workspace_id, host, rows)` tuples are +//! then shipped into the single blocking task that owns the DB +//! handle. + +use std::collections::HashMap; + +use claudette::agent::interactive_host::InteractiveHost; +use claudette::db::{Database, InteractiveSessionRow}; +use serde::Serialize; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::state::AppState; + +/// Wire payload for `interactive://orphans-detected`. Emitted once +/// during boot if the reconciler finds any orphan sids on the host +/// that the DB doesn't know about. The frontend uses this to show a +/// one-shot toast / banner with a "Clean up" button bound to the +/// `interactive_cleanup_orphans` command. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OrphansDetectedPayload { + pub sids: Vec, +} + +/// Callback type used by the inner reconciler to emit the +/// `interactive://orphans-detected` Tauri event. Production wires this +/// to `AppHandle::emit`; tests inject a closure that records the +/// emission (or returns an error to exercise the emit-failure log +/// branch). +pub(crate) type OrphanEmitter = + Box Result<(), String> + Send>; + +/// Reconcile every persisted `interactive_sessions` row currently in +/// `state = 'running'` against the live host. See module docs for the +/// behavior contract. +/// +/// Spawned from `main.rs::setup` on a background Tokio task — the +/// startup path waits for none of this. The reconciler only writes to +/// the DB when the host's `status()` succeeds, so a transient +/// unavailable-host condition leaves the row alone for the next boot +/// to handle. +/// +/// Thin wrapper over [`reattach_interactive_sessions_inner`]: the +/// inner function takes a plain `&AppState` plus an emit callback so +/// it can be unit-tested without booting a real Tauri runtime. The +/// `AppHandle` is only used here to (a) borrow the managed `AppState` +/// and (b) wire `app.emit(...)` into the inner reconciler's +/// `OrphanEmitter` slot. +#[tracing::instrument(level = "info", target = "claudette::interactive", skip_all)] +pub async fn reattach_interactive_sessions_on_boot(app: AppHandle) { + let state = app.state::(); + let emit_app = app.clone(); + let emit_orphans: OrphanEmitter = Box::new(move |payload: &OrphansDetectedPayload| { + emit_app + .emit("interactive://orphans-detected", payload) + .map_err(|e| e.to_string()) + }); + reattach_interactive_sessions_inner(state.inner(), emit_orphans).await; +} + +/// Testable core of the boot reconciler. Takes a borrowed `AppState` +/// (the same managed instance the production entry hands in) plus a +/// callback that emits the `interactive://orphans-detected` payload. +/// +/// Splitting this out lets the unit tests in `mod tests` exercise the +/// flag-gate / DB-read-failure / per-workspace host resolution / +/// orphan-fallback / emit branches without constructing a real +/// `AppHandle` (which requires a full Tauri runtime). The behavior +/// is identical to the legacy combined function — see the module docs +/// for the full contract. +#[tracing::instrument(level = "info", target = "claudette::interactive", skip_all)] +pub(crate) async fn reattach_interactive_sessions_inner( + state: &AppState, + emit_orphans: OrphanEmitter, +) { + // Gate the reconciler on the experimental flag so users who have + // never enabled `claudeInteractiveEnabled` don't pay the cost of + // spawning the bundled `claudette-session-host` sidecar at every + // boot. Stale `running` rows from a prior install or a flag-toggle + // are left as-is in the DB; the next boot after the user re-enables + // the flag will reconcile them. + if !state.claude_interactive_enabled().await { + tracing::info!( + target: "claudette::interactive", + "boot reconciler: claudeInteractiveEnabled is off; skipping", + ); + return; + } + + let db_path = state.db_path.clone(); + + // Phase 1: fetch (a) all running rows for reclassification and + // (b) the full set of known sids for orphan detection in a single + // blocking task that opens and closes its own DB connection. Doing + // this on the async caller would block the multi-thread runtime on + // rusqlite I/O. + // + // We snapshot ALL sids (not just running ones) because a + // crashed/detached/stopped row is still a row the DB knows about — + // its sid must NOT count as an orphan even though the host might + // happen to still be holding the session under the same name. + let (pending, known_sids) = match tokio::task::spawn_blocking({ + let db_path = db_path.clone(); + move || -> Result<(Vec, Vec), String> { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + let running = db + .list_running_interactive_sessions() + .map_err(|e| e.to_string())?; + let known = db + .list_all_interactive_session_sids() + .map_err(|e| e.to_string())?; + Ok((running, known)) + } + }) + .await + { + Ok(Ok(pair)) => pair, + Ok(Err(err)) => { + tracing::warn!( + target: "claudette::interactive", + error = %err, + "boot reconciler: failed to read sessions; skipping" + ); + return; + } + Err(join_err) => { + tracing::warn!( + target: "claudette::interactive", + error = %join_err, + "boot reconciler: spawn_blocking failed; skipping" + ); + return; + } + }; + + if pending.is_empty() && known_sids.is_empty() { + // Fast path: no DB-tracked sessions AND no records to reconcile. + // We still skip the host probe here because resolving a host + // when the DB never had any interactive sessions would spawn + // the sidecar binary for no useful reason. The orphan check on + // a totally cold DB is also moot — Claudette could not have + // created any orphans without ever persisting a row. + return; + } + + // Group by workspace so we resolve each host exactly once even + // when a workspace has multiple stale rows. + let mut by_workspace: HashMap> = HashMap::new(); + for row in pending { + by_workspace + .entry(row.workspace_id.clone()) + .or_default() + .push(row); + } + + // Phase 2: resolve each workspace's host on the async caller. + // `interactive_host_for` is `async` (may spawn the sidecar), so + // we can't do this inside the single-threaded blocking task. We + // collect everything that resolves successfully into a list of + // `(workspace_id, host, rows)` tuples and ship that list into the + // one blocking task that owns the DB handle. + let mut resolved: Vec<(String, Arc, Vec)> = + Vec::with_capacity(by_workspace.len()); + for (workspace_id, rows) in by_workspace { + match state.interactive_host_for(&workspace_id).await { + Ok(host) => resolved.push((workspace_id, host, rows)), + Err(err) => { + tracing::warn!( + target: "claudette::interactive", + %workspace_id, + rows = rows.len(), + error = %err, + "boot reconciler: could not resolve host; leaving rows as running", + ); + } + } + } + + // Phase 2b: orphan detection. We need to ask SOME host + // `status()` to see what sessions actually exist. Every workspace + // shares the same backend selection (tmux server / sidecar + // socket), so the first successfully-resolved host is + // representative. If we couldn't resolve any host via the running + // rows (e.g. all of them failed host resolution OR there were no + // running rows but there were historical ones), fall back to + // resolving a host against the workspace of the first known sid + // we still have on file. In the cold case where no workspace can + // resolve a host, we skip orphan detection — the user can't have + // accumulated orphans we can clean up either. + let orphan_host: Option> = if let Some((_, host, _)) = resolved.first() + { + Some(Arc::clone(host)) + } else { + // No host resolved yet. Try to resolve one from any workspace + // that historically owned an interactive session. We probe + // `known_sids` for sids whose `claudette--` prefix + // points at a workspace we can look up. In practice this is + // rare — running rows would normally cover the same workspaces + // — but it matters when the only known DB rows are + // crashed/stopped and the user wants stale host sessions + // cleaned up. + let mut probed: Option> = None; + // The list_workspaces query is cheap; do it once. + let workspaces_for_probe = tokio::task::spawn_blocking({ + let db_path = db_path.clone(); + move || -> Result, String> { + let db = Database::open(&db_path).map_err(|e| e.to_string())?; + db.list_workspaces() + .map(|rows| rows.into_iter().map(|w| w.id).collect()) + .map_err(|e| e.to_string()) + } + }) + .await + .ok() + .and_then(|r| r.ok()) + .unwrap_or_default(); + for ws_id in workspaces_for_probe { + if let Ok(host) = state.interactive_host_for(&ws_id).await { + probed = Some(host); + break; + } + } + probed + }; + + if let Some(host) = orphan_host { + match claudette::interactive::detect_orphans(&known_sids, host.as_ref()).await { + Ok(orphans) if !orphans.is_empty() => { + let sids: Vec = orphans.iter().map(|s| s.0.clone()).collect(); + tracing::info!( + target: "claudette::interactive", + count = sids.len(), + "boot reconciler: detected orphan host sessions", + ); + // Stash sid → host into AppState so + // `interactive_cleanup_orphans` can stop them without + // needing to re-resolve which workspace they came from. + { + let mut map = state.interactive_orphans.write().await; + for sid in &sids { + map.insert(sid.clone(), Arc::clone(&host)); + } + } + let payload = OrphansDetectedPayload { sids }; + if let Err(err) = emit_orphans(&payload) { + tracing::warn!( + target: "claudette::interactive", + error = %err, + "boot reconciler: failed to emit orphans-detected event", + ); + } + } + Ok(_) => {} + Err(err) => { + tracing::warn!( + target: "claudette::interactive", + error = %err, + "boot reconciler: orphan detection failed; skipping", + ); + } + } + } + + if resolved.is_empty() { + return; + } + + // Phase 3: one blocking task, one DB connection, current-thread + // Tokio runtime so the `!Send` future from `reattach_rows` is + // legal. Per-workspace errors are logged and isolated; a single + // failing workspace must not abort the rest of the reconciliation. + let db_path_inner = db_path.clone(); + let join = tokio::task::spawn_blocking(move || -> Result<(), String> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + let db = Database::open(&db_path_inner).map_err(|e| e.to_string())?; + rt.block_on(async move { + for (workspace_id, host, rows) in resolved { + if let Err(err) = + claudette::interactive::reattach_rows(&db, &rows, host.as_ref()).await + { + tracing::warn!( + target: "claudette::interactive", + workspace_id = %workspace_id, + error = %err, + "boot reconciler: reattach_rows failed", + ); + } + } + }); + Ok(()) + }) + .await; + match join { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + target: "claudette::interactive", + error = %err, + "boot reconciler: blocking task setup failed", + ); + } + Err(join_err) => { + tracing::warn!( + target: "claudette::interactive", + error = %join_err, + "boot reconciler: join failed", + ); + } + } +} + +#[cfg(test)] +mod tests { + //! Boot-reconciler branch coverage. + //! + //! Tests construct an `AppState` directly (the same shape used by + //! `tray.rs`'s `fresh_state` helper) backed by an on-disk SQLite + //! file under a tempdir, pre-seed `interactive_hosts` with mocks + //! so `interactive_host_for` returns deterministic values, and + //! drive the inner reconciler with a recorded `OrphanEmitter` + //! closure. + //! + //! Branches under test (numbered from Task D1): + //! 1. flag OFF → early return (no host work). + //! 2. empty DB + empty known sids → fast-path return. + //! 3. single workspace, host knows session → row → `detached`. + //! 4. single workspace, host doesn't know → row → `crashed`. + //! 5. orphan fallback: no running rows but host has a claudette- sid the DB doesn't. + //! 6. host resolution failure for one workspace doesn't abort others. + //! 7. emit-failure path is logged and swallowed. + //! + //! Branch 6 of the plan (DB read failure on the running-rows query) is + //! intentionally NOT exercised: there is no race-free way to construct + //! a DB that the flag-check `get_app_setting` query can read but the + //! subsequent `list_running_interactive_sessions` query cannot, without + //! introducing a fault-injection hook into `Database::open` that lives + //! purely for this test. The branch is shallow (log + early return) + //! and the underlying `list_running_interactive_sessions` failure mode + //! is already covered by the DB-layer's own tests. + + use super::*; + use async_trait::async_trait; + use claudette::agent::interactive_host::{ + AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, + }; + use claudette::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; + use claudette::db::{Database, InteractiveSessionRow}; + use claudette::model::{AgentStatus, Repository, Workspace, WorkspaceStatus}; + use claudette::plugin_runtime::PluginRegistry; + use std::path::PathBuf; + use std::sync::Mutex as StdMutex; + use tempfile::TempDir; + + /// Spin up a fresh on-disk DB under a tempdir and return both the + /// tempdir guard (kept by the caller so the file outlives the test) + /// and the db_path used to construct the AppState. The DB is opened + /// once here to run migrations; subsequent code paths in the + /// reconciler re-open the same file. + fn make_db() -> (TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("claudette.db"); + let db = Database::open(&db_path).unwrap(); + // The interactive_sessions table FKs into workspaces(id); seed a + // placeholder repository so per-test workspace inserts succeed. + db.insert_repository(&make_repo("repo-1", "/tmp/repo1", "repo-1")) + .unwrap(); + (tmp, db_path) + } + + fn make_app_state(db_path: PathBuf) -> AppState { + // Match the construction shape used by `tray.rs::fresh_state` and + // `ipc.rs::fresh_app_state` — a discover against a nonexistent + // plugin dir yields an empty registry with no Lua/IO cost. + let plugins = PluginRegistry::discover(std::path::Path::new("/nonexistent")); + AppState::new(db_path, std::path::PathBuf::from("/tmp"), plugins) + } + + fn make_repo(id: &str, path: &str, name: &str) -> Repository { + Repository { + id: id.into(), + path: path.into(), + name: name.into(), + path_slug: name.into(), + icon: None, + created_at: String::new(), + setup_script: None, + custom_instructions: None, + sort_order: 0, + branch_rename_preferences: None, + setup_script_auto_run: false, + archive_script: None, + archive_script_auto_run: false, + base_branch: None, + default_remote: None, + required_inputs: None, + path_valid: true, + } + } + + fn make_workspace(id: &str, repo_id: &str, name: &str) -> Workspace { + Workspace { + id: id.into(), + repository_id: repo_id.into(), + name: name.into(), + branch_name: format!("claudette/{name}"), + worktree_path: None, + status: WorkspaceStatus::Active, + agent_status: AgentStatus::Idle, + status_line: String::new(), + created_at: String::new(), + sort_order: 0, + input_values: None, + } + } + + fn make_row(sid: &str, ws_id: &str, state: &str) -> InteractiveSessionRow { + InteractiveSessionRow { + sid: sid.into(), + workspace_id: ws_id.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, + } + } + + /// Drop-in `InteractiveHost` whose `status()` returns a fixed list + /// and which panics on every other trait method — the reconciler + /// must not call attach/send_input/etc on the boot path. + struct ProgrammableHost { + sessions: Vec, + } + + #[async_trait] + impl InteractiveHost for ProgrammableHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unreachable!("boot reconciler must not ensure_session") + } + async fn attach(&self, _sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + unreachable!("boot reconciler must not attach") + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unreachable!("boot reconciler must not send_input") + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unreachable!("boot reconciler must not capture_screen") + } + async fn resize(&self, _sid: &SessionId, _rows: u16, _cols: u16) -> Result<(), HostError> { + unreachable!("boot reconciler must not resize") + } + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + unreachable!("boot reconciler must not detach") + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unreachable!("boot reconciler must not stop") + } + async fn status(&self) -> Result { + Ok(HostStatus { + host_version: "mock".into(), + sessions: self.sessions.clone(), + }) + } + } + + /// Host whose `status()` always errors. Used to assert that one + /// workspace's host failure doesn't abort the reconciler for other + /// workspaces. + struct ErroringHost; + + #[async_trait] + impl InteractiveHost for ErroringHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unreachable!() + } + async fn attach(&self, _sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + unreachable!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unreachable!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unreachable!() + } + async fn resize(&self, _sid: &SessionId, _rows: u16, _cols: u16) -> Result<(), HostError> { + unreachable!() + } + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + unreachable!() + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unreachable!() + } + async fn status(&self) -> Result { + Err(HostError::Unavailable("mock".into())) + } + } + + /// Host whose every method panics — used as the negative witness for + /// the flag-OFF and empty-DB fast paths. Any call into this host + /// would be a regression in the early-return logic. + struct PanickyHost; + + #[async_trait] + impl InteractiveHost for PanickyHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unreachable!("flag-OFF / empty-DB path must not touch host") + } + async fn attach(&self, _sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + unreachable!("flag-OFF / empty-DB path must not touch host") + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unreachable!("flag-OFF / empty-DB path must not touch host") + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unreachable!("flag-OFF / empty-DB path must not touch host") + } + async fn resize(&self, _sid: &SessionId, _rows: u16, _cols: u16) -> Result<(), HostError> { + unreachable!("flag-OFF / empty-DB path must not touch host") + } + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + unreachable!("flag-OFF / empty-DB path must not touch host") + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unreachable!("flag-OFF / empty-DB path must not touch host") + } + async fn status(&self) -> Result { + panic!("status must not be called on flag-OFF / empty-DB fast path") + } + } + + /// Build a recording `OrphanEmitter` and return a handle to inspect + /// the captured payloads after the reconciler returns. + fn recording_emitter() -> (OrphanEmitter, Arc>>>) { + let log: Arc>>> = Arc::new(StdMutex::new(Vec::new())); + let log_clone = Arc::clone(&log); + let emitter: OrphanEmitter = Box::new(move |payload: &OrphansDetectedPayload| { + log_clone.lock().unwrap().push(payload.sids.clone()); + Ok(()) + }); + (emitter, log) + } + + fn failing_emitter() -> OrphanEmitter { + Box::new(|_| Err("simulated emit failure".to_string())) + } + + // --- Branch 1: flag OFF early return ----------------------------------- + + #[tokio::test] + async fn flag_off_early_return_does_not_touch_host() { + // Flag absent (defaults to disabled). Pre-seed a `running` row + // and register a `PanickyHost` for its workspace — any host call + // means the early-return failed. + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-ws1-aaaaaaaa", "ws-1", "running")) + .unwrap(); + + let state = make_app_state(db_path); + // Pre-seed a host that panics on any call so a regression + // bypassing the gate fails loudly. + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::new(PanickyHost) as _); + + let (emitter, log) = recording_emitter(); + reattach_interactive_sessions_inner(&state, emitter).await; + + // Row is left exactly as inserted: still `running`. + let row = db + .get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .unwrap(); + assert_eq!(row.state, "running", "flag-OFF must not reclassify rows"); + + // No orphans emitted. + assert!(log.lock().unwrap().is_empty()); + } + + // --- Branch 2: empty DB fast-path -------------------------------------- + + #[tokio::test] + async fn empty_db_and_known_sids_fast_path_does_not_touch_host() { + // Flag ON, but the DB has neither running rows nor any + // historical sids. The fast-path branch must return before any + // host resolution. + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + + let state = make_app_state(db_path); + // Pre-seed PanickyHost just to prove no host is touched; the + // empty-DB branch returns before any `interactive_host_for` call. + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::new(PanickyHost) as _); + + let (emitter, log) = recording_emitter(); + reattach_interactive_sessions_inner(&state, emitter).await; + + assert!( + log.lock().unwrap().is_empty(), + "empty-DB fast path must not emit", + ); + // No orphans recorded into AppState either. + assert!(state.interactive_orphans.read().await.is_empty()); + } + + // --- Branch 3: host knows session → detached --------------------------- + + #[tokio::test] + async fn single_workspace_host_knows_session_marks_row_detached() { + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + let sid = "claudette-ws1-aaaaaaaa"; + db.create_interactive_session(&make_row(sid, "ws-1", "running")) + .unwrap(); + + let state = make_app_state(db_path); + state.interactive_hosts.write().await.insert( + "ws-1".to_string(), + Arc::new(ProgrammableHost { + sessions: vec![HostSessionSummary { + sid: SessionId(sid.into()), + pid: None, + running: true, + }], + }) as _, + ); + + let (emitter, log) = recording_emitter(); + reattach_interactive_sessions_inner(&state, emitter).await; + + let row = db.get_interactive_session(sid).unwrap().unwrap(); + assert_eq!(row.state, "detached", "host-knows → detached"); + assert!(row.crash_reason.is_none()); + // No orphans — the host reports exactly the DB-known sid. + assert!(log.lock().unwrap().is_empty()); + } + + // --- Branch 4: host doesn't know session → crashed --------------------- + + #[tokio::test] + async fn single_workspace_host_missing_session_marks_row_crashed() { + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + let sid = "claudette-ws1-bbbbbbbb"; + db.create_interactive_session(&make_row(sid, "ws-1", "running")) + .unwrap(); + + let state = make_app_state(db_path); + state.interactive_hosts.write().await.insert( + "ws-1".to_string(), + Arc::new(ProgrammableHost { sessions: vec![] }) as _, + ); + + let (emitter, _log) = recording_emitter(); + reattach_interactive_sessions_inner(&state, emitter).await; + + let row = db.get_interactive_session(sid).unwrap().unwrap(); + assert_eq!(row.state, "crashed", "host-missing → crashed"); + assert_eq!(row.crash_reason.as_deref(), Some("host missing")); + } + + // --- Branch 5: orphan fallback when no running rows -------------------- + + #[tokio::test] + async fn orphan_fallback_probes_workspace_for_host_and_records_orphans() { + // No `running` rows, but the DB tracks a historical sid (state = + // 'crashed') for ws-1 — `list_all_interactive_session_sids` will + // return it. The fallback should probe ws-1's host, find an + // unrelated claudette- sid still alive, and record it as an + // orphan in `AppState::interactive_orphans` plus emit the event. + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + let known_sid = "claudette-ws1-knownsid"; + db.create_interactive_session(&make_row(known_sid, "ws-1", "crashed")) + .unwrap(); + + let orphan_sid = "claudette-ws1-orphan11"; + let state = make_app_state(db_path); + state.interactive_hosts.write().await.insert( + "ws-1".to_string(), + Arc::new(ProgrammableHost { + sessions: vec![ + HostSessionSummary { + sid: SessionId(known_sid.into()), + pid: None, + running: true, + }, + HostSessionSummary { + sid: SessionId(orphan_sid.into()), + pid: None, + running: true, + }, + ], + }) as _, + ); + + let (emitter, log) = recording_emitter(); + reattach_interactive_sessions_inner(&state, emitter).await; + + // Orphan got stashed in AppState so `interactive_cleanup_orphans` + // can stop it without re-resolving the host. + let orphans = state.interactive_orphans.read().await; + assert!( + orphans.contains_key(orphan_sid), + "orphan sid must be stashed in AppState, got {:?}", + orphans.keys().collect::>(), + ); + assert!( + !orphans.contains_key(known_sid), + "DB-known sid must not be flagged as orphan", + ); + + // Emit happened with the orphan payload. + let emissions = log.lock().unwrap(); + assert_eq!(emissions.len(), 1, "exactly one orphans-detected emission"); + assert_eq!(emissions[0], vec![orphan_sid.to_string()]); + } + + // --- Branch 7 (plan numbering): per-workspace host failure isolation --- + + #[tokio::test] + async fn host_resolution_failure_for_one_workspace_does_not_abort_others() { + // Two workspaces, each with a `running` row. ws-1 has an erroring + // host (`status()` returns Err); ws-2 has a normal host that + // knows its session. The reconciler must still reclassify ws-2's + // row even though ws-1's host errored. + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + db.insert_workspace(&make_workspace("ws-2", "repo-1", "feature")) + .unwrap(); + let ws1_sid = "claudette-ws1-cccccccc"; + let ws2_sid = "claudette-ws2-dddddddd"; + db.create_interactive_session(&make_row(ws1_sid, "ws-1", "running")) + .unwrap(); + db.create_interactive_session(&make_row(ws2_sid, "ws-2", "running")) + .unwrap(); + + let state = make_app_state(db_path); + // ws-1: host status() errors. + state + .interactive_hosts + .write() + .await + .insert("ws-1".to_string(), Arc::new(ErroringHost) as _); + // ws-2: host knows its session. + state.interactive_hosts.write().await.insert( + "ws-2".to_string(), + Arc::new(ProgrammableHost { + sessions: vec![HostSessionSummary { + sid: SessionId(ws2_sid.into()), + pid: None, + running: true, + }], + }) as _, + ); + + let (emitter, _log) = recording_emitter(); + reattach_interactive_sessions_inner(&state, emitter).await; + + // ws-1's row is left as `running` — the host's `status()` errored + // so `reattach_rows` propagates the error and the row stays + // unclassified (per `reattach_rows`'s "surface host errors" + // contract). + let ws1_row = db.get_interactive_session(ws1_sid).unwrap().unwrap(); + assert_eq!( + ws1_row.state, "running", + "ws-1's row must remain `running` because its host errored", + ); + + // ws-2's row was reclassified normally. + let ws2_row = db.get_interactive_session(ws2_sid).unwrap().unwrap(); + assert_eq!( + ws2_row.state, "detached", + "ws-2's row must be reclassified even though ws-1's host failed", + ); + } + + // --- Branch 8 (plan calls it part of the orphan emit branch): emit failure + // path is logged and swallowed. ------------------------------------- + + #[tokio::test] + async fn orphan_emit_failure_is_swallowed_and_orphans_still_recorded() { + // Even when the emit closure returns Err, the reconciler must + // still stash the orphan into `AppState::interactive_orphans` so + // a subsequent `interactive_cleanup_orphans` invocation can find + // and stop it. The map is populated BEFORE the emit call — + // confirm that ordering hasn't regressed. + let (_tmp, db_path) = make_db(); + let db = Database::open(&db_path).unwrap(); + db.set_app_setting("claudeInteractiveEnabled", "true") + .unwrap(); + db.insert_workspace(&make_workspace("ws-1", "repo-1", "fix-bug")) + .unwrap(); + let known_sid = "claudette-ws1-knownsid"; + let orphan_sid = "claudette-ws1-orphan99"; + db.create_interactive_session(&make_row(known_sid, "ws-1", "crashed")) + .unwrap(); + + let state = make_app_state(db_path); + state.interactive_hosts.write().await.insert( + "ws-1".to_string(), + Arc::new(ProgrammableHost { + sessions: vec![ + HostSessionSummary { + sid: SessionId(known_sid.into()), + pid: None, + running: true, + }, + HostSessionSummary { + sid: SessionId(orphan_sid.into()), + pid: None, + running: true, + }, + ], + }) as _, + ); + + // Failing emitter so the reconciler must log + continue. + reattach_interactive_sessions_inner(&state, failing_emitter()).await; + + let orphans = state.interactive_orphans.read().await; + assert!( + orphans.contains_key(orphan_sid), + "orphan must be stashed even when the emit closure errored", + ); + } +} diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index e9bc01a72..7632f2abe 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,77 @@ 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`, `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. Accepted on the wire for forward +/// compatibility (Task G4 may want it) but not yet plumbed into +/// `InteractiveHookEvent`. +/// +/// 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, + params: &serde_json::Value, +) -> Result { + let sid = params + .get("sid") + .and_then(|v| v.as_str()) + .ok_or("missing sid")? + .to_string(); + let kind_raw = 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); + // 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(); + + 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` /// helper so CLI-driven archives perform the same agent process /// teardown, env-watcher cleanup, and MCP supervisor shutdown the GUI @@ -1577,6 +1650,7 @@ mod tests { "submit_agent_approval", "submit_plan_approval", "steer_queued_chat_message", + "chat_hook", ] { assert!(METHODS.contains(&method), "{method} missing from METHODS"); } @@ -1886,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/main.rs b/src-tauri/src/main.rs index af7c81fda..e57a63a3d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,6 +6,7 @@ mod app_info; mod boot_probation; mod claude_teammate_bridge; mod commands; +mod interactive_lifecycle; mod ipc; mod mdns; mod missing_cli; @@ -893,6 +894,17 @@ fn main() { } }); + // Reconcile any `interactive_sessions` rows left in + // `state = 'running'` from the previous boot. Runs in the + // background so a slow tmux/sidecar probe doesn't delay + // the UI; the reconciler is idempotent and only writes + // when the host responds, so repeated boots eventually + // converge even if the first attempt finds nothing. + let reattach_app = app.handle().clone(); + tauri::async_runtime::spawn(async move { + interactive_lifecycle::reattach_interactive_sessions_on_boot(reattach_app).await; + }); + // Start the local IPC server the `claudette` CLI talks to. // Spawned async on the Tauri runtime; the resulting // `IpcServer` + discovery file are managed so they live for @@ -1075,6 +1087,15 @@ 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, + commands::interactive::interactive_list_orphans, + commands::interactive::interactive_cleanup_orphans, // 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 674ed7c47..3a0f35a85 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -5,7 +5,8 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use claudette::agent::AgentSession; -use tokio::sync::{RwLock, Semaphore}; +use claudette::agent::interactive_host::InteractiveHost; +use tokio::sync::{RwLock, Semaphore, mpsc}; use claudette::claude_help::ClaudeFlagDef; use claudette::env_provider::{EnvCache, EnvWatcher}; @@ -59,6 +60,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 +780,40 @@ 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>>>, + /// 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>, + /// Orphan interactive sessions detected at boot — sids the host + /// reported but the DB did NOT track. Populated by the boot + /// reconciler ([`crate::interactive_lifecycle`]) via + /// [`claudette::interactive::detect_orphans`], drained by the + /// `interactive_cleanup_orphans` Tauri command. Each entry maps + /// `sid → host that reported it` so the cleanup command can call + /// `host.stop()` without re-resolving the host (and without needing + /// the orphan to belong to a workspace Claudette still tracks). + pub interactive_orphans: RwLock>>, } impl AppState { @@ -791,6 +870,10 @@ 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())), + interactive_hosts: RwLock::new(HashMap::new()), + interactive_sessions: RwLock::new(HashMap::new()), + interactive_orphans: RwLock::new(HashMap::new()), } } @@ -821,6 +904,302 @@ 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. + /// + /// 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, + 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. + /// + /// 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 + .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" + ); + } + } + } + + /// 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. 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. + /// + /// 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 { + if let Ok(p) = std::env::var("CLAUDETTE_CLI") { + let candidate = PathBuf::from(p); + // Honour the documented contract: returning `None` when the + // resolved candidate does not exist on disk. Without this + // check, a stale or test-only `CLAUDETTE_CLI` override would + // bypass the existence guard that applies to the + // current_exe + dev-fallback branches below. + if candidate.exists() { + return Some(candidate); + } + return None; + } + let exe = std::env::current_exe().ok()?; + 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. + /// 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"); + // 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, + &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 @@ -841,6 +1220,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": [] } } diff --git a/src/agent/claude_interactive.rs b/src/agent/claude_interactive.rs new file mode 100644 index 000000000..83dacbe7e --- /dev/null +++ b/src/agent/claude_interactive.rs @@ -0,0 +1,501 @@ +//! 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}; +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). +/// +/// 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 +/// 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(()) + } +} + +/// 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 { + #[cfg(unix)] + { + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.')) + { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } + } + #[cfg(windows)] + { + 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('\'', "'\\''")) + } + } +} + +#[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()); + } + + #[cfg(unix)] + #[test] + fn shell_quote_leaves_plain_paths_alone() { + assert_eq!( + shell_quote("/usr/local/bin/claudette-cli"), + "/usr/local/bin/claudette-cli" + ); + } + + #[cfg(unix)] + #[test] + fn shell_quote_wraps_paths_with_spaces() { + assert_eq!( + shell_quote("/Users/me/Application Support/cli"), + "'/Users/me/Application Support/cli'" + ); + } + + #[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 settings_overlay_materialize_fails_when_parent_is_a_regular_file() { + // Use a regular file as the `parent` arg. `create_dir_all` will then + // attempt to create a directory beneath a non-directory component, + // which fails (NotADirectory on Unix; similar error class on Windows). + let file = tempfile::NamedTempFile::new().unwrap(); + let parent_as_file = file.path(); + assert!( + parent_as_file.is_file(), + "precondition: tempfile path must exist as a regular file" + ); + + let result = SettingsOverlay::materialize( + parent_as_file, + "claudette-x-y", + Path::new("/abs/path/to/claudette-cli"), + ); + assert!( + result.is_err(), + "expected Err when parent path is a regular file, got Ok" + ); + } + + #[test] + fn settings_overlay_cleanup_is_idempotent_when_dir_already_removed() { + let tmp = tempfile::tempdir().unwrap(); + let overlay = SettingsOverlay::materialize( + tmp.path(), + "claudette-x-y", + Path::new("/abs/path/to/claudette-cli"), + ) + .unwrap(); + // Yank the overlay out from under cleanup() by deleting the directory + // tree manually first. + std::fs::remove_dir_all(&overlay.dir).unwrap(); + assert!(!overlay.dir.exists()); + // Second cleanup must still succeed (idempotent). + overlay + .cleanup() + .expect("cleanup() on a missing overlay should be Ok"); + } + + #[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:?}" + ); + } + + /// Cover `InteractiveSession::start` happy + failure paths without + /// requiring a real host. The mock host's `ensure_session` is the + /// only method exercised by `start`; the recorded call lets us pin + /// that the overlay path is passed through `SessionSpec::claude_config_dir` + /// rather than the caller-supplied value. + mod start_tests { + use super::*; + use crate::agent::interactive_host::{ + AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, + }; + use crate::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; + use async_trait::async_trait; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct RecordingHost { + ensure_calls: Mutex>, + fail_with: Mutex>, + } + + #[async_trait] + impl InteractiveHost for RecordingHost { + async fn ensure_session( + &self, + sid: &SessionId, + spec: &SessionSpec, + ) -> Result { + if let Some(err) = self.fail_with.lock().unwrap().take() { + return Err(err); + } + self.ensure_calls + .lock() + .unwrap() + .push((sid.clone(), spec.clone())); + Ok(HostHandle { + sid: sid.clone(), + pid: Some(42), + rows: spec.rows, + cols: spec.cols, + }) + } + async fn attach( + &self, + _sid: &SessionId, + ) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!("attach should not be called by start()") + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!("send_input should not be called by start()") + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!("capture_screen should not be called by start()") + } + async fn resize( + &self, + _sid: &SessionId, + _rows: u16, + _cols: u16, + ) -> Result<(), HostError> { + unimplemented!("resize should not be called by start()") + } + async fn detach( + &self, + _sid: &SessionId, + _attach_id: AttachId, + ) -> Result<(), HostError> { + unimplemented!("detach should not be called by start()") + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unimplemented!("stop should not be called by start()") + } + async fn status(&self) -> Result { + Ok(HostStatus { + host_version: "mock".into(), + sessions: Vec::::new(), + }) + } + } + + fn make_spec() -> SessionSpec { + SessionSpec { + working_dir: "/tmp/wd".into(), + rows: 24, + cols: 80, + claude_binary: "/usr/local/bin/claude".into(), + claude_args: vec![], + env: vec![], + claude_config_dir: "/caller-supplied/should-be-overwritten".into(), + } + } + + #[tokio::test] + async fn start_materializes_overlay_and_overrides_config_dir() { + let parent = tempfile::tempdir().unwrap(); + let host = Arc::new(RecordingHost::default()); + let session = InteractiveSession::start( + "ws1", + host.clone(), + make_spec(), + parent.path(), + Path::new("/abs/path/to/claudette-cli"), + ) + .await + .expect("start should succeed"); + + // Sid prefix is correct + overlay was materialized on disk. + assert!( + session.sid.starts_with("claudette-ws1-"), + "unexpected sid: {}", + session.sid + ); + assert!( + session.overlay.dir.join("settings.json").exists(), + "settings.json missing in {:?}", + session.overlay.dir + ); + + // ensure_session was called once with the overlay dir piped + // into spec.claude_config_dir (not the caller-supplied one). + let calls = host.ensure_calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "expected exactly one ensure_session call"); + let (sid, spec) = &calls[0]; + assert_eq!(sid.as_str(), session.sid); + assert_eq!( + spec.claude_config_dir, + session.overlay.dir.to_string_lossy() + ); + assert_ne!( + spec.claude_config_dir, "/caller-supplied/should-be-overwritten", + "start() must overwrite the caller-supplied config dir with the overlay path" + ); + } + + #[tokio::test] + async fn start_propagates_host_error_from_ensure_session() { + let parent = tempfile::tempdir().unwrap(); + let host = Arc::new(RecordingHost::default()); + *host.fail_with.lock().unwrap() = Some(HostError::Unavailable("no host".into())); + + let result = InteractiveSession::start( + "ws1", + host.clone(), + make_spec(), + parent.path(), + Path::new("/abs/path/to/claudette-cli"), + ) + .await; + + assert!(matches!(result, Err(HostError::Unavailable(_)))); + } + + #[tokio::test] + async fn start_propagates_overlay_materialize_failure() { + // Use a regular file as `overlay_parent` so create_dir_all + // returns NotADirectory and start() maps it into HostError::Other. + let file = tempfile::NamedTempFile::new().unwrap(); + let host = Arc::new(RecordingHost::default()); + + let result = InteractiveSession::start( + "ws1", + host.clone(), + make_spec(), + file.path(), + Path::new("/abs/path/to/claudette-cli"), + ) + .await; + + assert!(matches!(result, Err(HostError::Other(_)))); + // ensure_session must NOT have been called: overlay failure is + // fatal before we touch the host. + assert!(host.ensure_calls.lock().unwrap().is_empty()); + } + } +} diff --git a/src/agent/harness.rs b/src/agent/harness.rs index dc3d35f8b..c335a23e1 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, @@ -257,6 +327,9 @@ impl AgentSession { do not call start_compact() on this harness." .to_string(), ), + Self::ClaudeInteractive(_) => { + Err("ClaudeInteractive does not support native context compaction".to_string()) + } Self::CodexAppServer(session) => session.start_compact().await, #[cfg(feature = "pi-sdk")] Self::PiSdk(session) => session.start_compact().await, @@ -269,6 +342,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 +376,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/interactive_host/availability.rs b/src/agent/interactive_host/availability.rs new file mode 100644 index 000000000..c40b43b40 --- /dev/null +++ b/src/agent/interactive_host/availability.rs @@ -0,0 +1,114 @@ +//! 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 = crate::process::command("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 { + 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 = 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().expect("poisoned") = 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)); + assert!(version_at_least("3.4-rc1", 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); + } +} diff --git a/src/agent/interactive_host/conformance.rs b/src/agent/interactive_host/conformance.rs new file mode 100644 index 000000000..8a102a12b --- /dev/null +++ b/src/agent/interactive_host/conformance.rs @@ -0,0 +1,160 @@ +//! 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) { + // 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(); + assert!( + !st.sessions.iter().any(|s| s.sid == fx.sid && s.running), + "session {:?} still listed as running after stop", + fx.sid + ); +} + +async fn drain_until_contains(stream: &mut S, needle: &str, total: Duration) -> bool +where + S: futures::Stream + Unpin, +{ + 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) +} diff --git a/src/agent/interactive_host/mod.rs b/src/agent/interactive_host/mod.rs new file mode 100644 index 000000000..1b894ad6b --- /dev/null +++ b/src/agent/interactive_host/mod.rs @@ -0,0 +1,85 @@ +//! 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), +} + +/// 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(), + ))) +} diff --git a/src/agent/interactive_host/sidecar.rs b/src/agent/interactive_host/sidecar.rs new file mode 100644 index 000000000..085a14c5e --- /dev/null +++ b/src/agent/interactive_host/sidecar.rs @@ -0,0 +1,907 @@ +//! `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::io::{AsyncRead, AsyncWrite}; +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 (r, w) = stream.split(); + Ok(Self::spawn_tasks(r, w)) + } + + /// Spawn the reader/writer tasks against a pre-handshaked split stream. + /// Generic over any `AsyncRead`/`AsyncWrite` halves so unit tests can + /// drive the reader/writer fault paths with `tokio::io::duplex` rather + /// than a real socket. Production callers go through + /// [`ConnHandle::connect`]. + fn spawn_tasks(mut r: R, mut w: W) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + 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, + }); + } + }); + + 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); + handshake_streams(&mut r, &mut w).await?; + } + Ok(stream) +} + +/// Run the Hello / HelloAck handshake against an arbitrary +/// `AsyncRead`/`AsyncWrite` pair. Extracted from [`open_handshaked`] so the +/// fault paths (non-HelloAck response, `HelloNack`, malformed first frame, +/// EOF before any frame) can be exercised by unit tests over +/// `tokio::io::duplex` without a real socket binary in the way. +async fn handshake_streams(r: &mut R, w: &mut W) -> std::io::Result<()> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + 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(w, &bytes).await?; + let first = read_frame(r).await?; + let inbound: InboundFrame = serde_json::from_slice(&first).map_err(std::io::Error::other)?; + match inbound { + InboundFrame::Response { + response: Response::HelloAck { .. }, + .. + } => Ok(()), + 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. + /// + /// **Known limitation (tracked in CLAUDE.md):** the cached `ConnHandle` + /// in `OnceCell` is never reset if the underlying connection dies. + /// If the bundled `claudette-session-host` sidecar exits (e.g., 600s + /// idle timer) while Claudette is still running, subsequent + /// `interactive_*` commands fail with "conn closed" until Claudette + /// is restarted. Resolution: replace `OnceCell` with + /// `Mutex>` and reconnect on dead-conn detection. + /// FIXME: implement reconnect (see CLAUDE.md "Known limitations"). + 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 = crate::process::command(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 crate::process::std_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 = std_command(&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 = std_command(&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); + } + + // --- ConnHandle / handshake fault-path unit tests (Task B1) ------------ + // + // These tests drive `handshake_streams` and `ConnHandle::spawn_tasks` + // against `tokio::io::duplex` rather than a real `claudette-session-host` + // socket, so they pin the reader/writer/handshake fault paths in plain + // unit-test scope (no spawned sidecar, no `--ignored` gate). + + use crate::agent::interactive_protocol::frame::{read_frame, write_frame}; + use crate::agent::interactive_protocol::{InboundFrame, Request, RequestEnvelope, Response}; + use tokio::io::duplex; + + /// Read the client's Hello envelope off `r`. Used by the duplex-backed + /// handshake tests so the server side advances past the client's first + /// write before doing anything fault-y. + async fn drain_client_hello(r: &mut R) -> RequestEnvelope + where + R: tokio::io::AsyncRead + Unpin, + { + let bytes = read_frame(r).await.expect("client hello frame"); + serde_json::from_slice::(&bytes).expect("hello envelope parses") + } + + /// Helper: serialize an `InboundFrame` to a length-prefixed wire frame. + async fn write_inbound(w: &mut W, frame: &InboundFrame) + where + W: tokio::io::AsyncWrite + Unpin, + { + let bytes = serde_json::to_vec(frame).expect("frame serializes"); + write_frame(w, &bytes).await.expect("write inbound frame"); + } + + /// Step 1: Malformed first frame. The server writes valid framing + /// (length-prefix + payload) but the payload is not JSON, so the + /// handshake parse fails. We expect `handshake_streams` to surface the + /// `serde_json` parse error as an `io::Error::other(...)`. + #[tokio::test] + async fn handshake_rejects_non_hello_first_frame() { + let (mut client_r, mut server_w) = duplex(4096); + let (mut server_r, mut client_w) = duplex(4096); + + // Server side: consume the client Hello, then send a malformed + // JSON payload as the "first" response frame. + let server = tokio::spawn(async move { + let _hello = drain_client_hello(&mut server_r).await; + // Length prefix says 5 bytes, payload is literally "hello" — + // valid framing, invalid JSON. + write_frame(&mut server_w, b"hello") + .await + .expect("server writes garbage payload"); + }); + + let res = handshake_streams(&mut client_r, &mut client_w).await; + server.await.expect("server task"); + let err = res.expect_err("handshake should reject malformed first frame"); + assert_eq!(err.kind(), std::io::ErrorKind::Other); + } + + /// Step 2: `HelloNack` branch. The server completes a real framed + /// response, but with a `HelloNack` instead of `HelloAck`. The + /// production code surfaces this via `io::Error::other` formatted with + /// the response debug — so the error message must mention HelloNack. + #[tokio::test] + async fn handshake_surfaces_hello_nack() { + let (mut client_r, mut server_w) = duplex(4096); + let (mut server_r, mut client_w) = duplex(4096); + + let server = tokio::spawn(async move { + let _hello = drain_client_hello(&mut server_r).await; + let nack = InboundFrame::Response { + request_id: 0, + response: Response::HelloNack { + reason: "protocol mismatch".into(), + supported_versions: vec![2, 3], + }, + }; + write_inbound(&mut server_w, &nack).await; + }); + + let res = handshake_streams(&mut client_r, &mut client_w).await; + server.await.expect("server task"); + let err = res.expect_err("handshake should surface HelloNack"); + let msg = err.to_string(); + assert!( + msg.contains("HelloNack"), + "expected HelloNack in error message, got: {msg}" + ); + assert!( + msg.contains("protocol mismatch"), + "expected nack reason in error message, got: {msg}" + ); + } + + /// Step 3: EOF mid-stream wakes inflight waiters. Build a `ConnHandle` + /// against a duplex stream that's already past the handshake, submit a + /// `request()`, then drop the server side. The reader task should + /// notice EOF and drain the inflight table with a synthetic error + /// response, so the awaiter resolves promptly with `HostError::Other` + /// rather than hanging forever. + #[tokio::test] + async fn request_wakes_when_reader_sees_eof() { + // client-write -> server-read so the writer task can flush; the + // server side immediately closes after consuming one envelope. + let (client_r, server_w) = duplex(4096); + let (mut server_r, client_w) = duplex(4096); + + let conn = ConnHandle::spawn_tasks(client_r, client_w); + + // Wait for the writer task to flush the request, then close both + // halves of the server side. Dropping `server_w` and `server_r` + // closes the duplex pair from the peer's perspective — the reader + // task's `read_frame` then returns Err and the inflight drain + // path runs. + let server = tokio::spawn(async move { + let _envelope = read_frame(&mut server_r).await.expect("server reads req"); + // Drop both halves to surface EOF to the client side. + drop(server_r); + drop(server_w); + }); + + // request() awaits the inflight oneshot. The reader task's + // synthetic Error response keeps this from hanging. + let resp = tokio::time::timeout( + std::time::Duration::from_secs(2), + conn.request(Request::Status), + ) + .await + .expect("request() must not hang after EOF") + .expect("inflight resolves to a Response, not a channel-drop error"); + server.await.expect("server task"); + + match resp { + Response::Error { + message, + recoverable, + } => { + assert!(!recoverable, "synthetic close error is not recoverable"); + assert!( + message.contains("connection closed"), + "expected 'connection closed' in synthetic error, got: {message}" + ); + } + other => panic!("expected synthetic Error on EOF, got: {other:?}"), + } + } + + /// Step 4: Writer task dies → next `request()` fails. We can't kill the + /// writer task directly, but closing the *reader* side of the duplex + /// pair (the side the writer task writes to) makes `write_frame` fail + /// and the writer task exits via its `break`. After the writer task is + /// gone the mpsc receiver is dropped, so `conn.request()`'s + /// `tx_out.send(...)` returns `Err`, which the production code maps to + /// `HostError::Other("conn closed")`. + #[tokio::test] + async fn request_fails_when_writer_task_dies() { + let (client_r, server_w) = duplex(4096); + let (server_r, client_w) = duplex(4096); + + let conn = ConnHandle::spawn_tasks(client_r, client_w); + + // Close the server side so writes fail. We have to drop both + // halves: the writer task writes into `client_w` whose peer is + // `server_r` — dropping `server_r` makes the next write_all fail. + // We also drop `server_w` so the reader task sees EOF and won't + // race with us. + drop(server_r); + drop(server_w); + + // Give the writer task a chance to attempt a write and fail. The + // mpsc channel has capacity 256, so the *first* request just + // queues; the writer task picks it up, the write_all fails, and + // the task exits. We loop a few times to be robust against + // scheduling. + let mut saw_err = None; + for _ in 0..32 { + match tokio::time::timeout( + std::time::Duration::from_millis(200), + conn.request(Request::Status), + ) + .await + { + Ok(Ok(_resp)) => { + // The reader task may have raced ahead and drained + // inflight with a synthetic Error before the writer + // task even noticed — that's also valid evidence + // that the connection is unusable, but we want to + // exercise the `tx_out.send` failure path + // specifically, so keep trying until the mpsc is + // actually closed. + continue; + } + Ok(Err(e)) => { + saw_err = Some(e); + break; + } + Err(_) => { + panic!("request() hung after writer was supposed to die"); + } + } + } + + let err = saw_err.expect("request() must error once the writer task has dropped rx_out"); + let msg = err.to_string(); + assert!( + msg.contains("conn closed") || msg.contains("connection closed"), + "expected closed-connection error, got: {msg}" + ); + } + + /// Step 5: `try_connect` failure variants. Pointing at a path that + /// definitely doesn't exist must surface as an `io::Error` — typically + /// `NotFound` on Unix domain sockets (the file isn't there) or + /// `ConnectionRefused` on Windows named pipes (no server listening). + #[tokio::test] + async fn try_connect_fails_on_missing_path() { + // Build a path that cannot exist: a temp dir we just removed. + let tmp = tempfile::tempdir().expect("tempdir"); + let socket_path = tmp.path().join("definitely-not-a-socket.sock"); + // Ensure the path doesn't exist by being inside a still-live + // tempdir but never created. + assert!(!socket_path.exists()); + + let err = try_connect(&socket_path) + .await + .expect_err("try_connect should fail for a non-existent socket path"); + let kind = err.kind(); + assert!( + matches!( + kind, + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ), + "expected NotFound or ConnectionRefused, got {kind:?}: {err}" + ); + } +} diff --git a/src/agent/interactive_host/tmux.rs b/src/agent/interactive_host/tmux.rs new file mode 100644 index 000000000..44451fbc4 --- /dev/null +++ b/src/agent/interactive_host/tmux.rs @@ -0,0 +1,718 @@ +//! `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::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())) + } + + /// 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 +/// `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) => { + 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, + }); + 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()) { + 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(), + }); + 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) => { + 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, + }); + 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 { + crate::process::std_command("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 = crate::process::command("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 = crate::process::command("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() { + 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); + 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 = crate::process::command("tmux") + .args(["pipe-pane", "-O", "-t", sid.as_str(), &pipe_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 pipe-pane setup failed" + ); + 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}")))?; + // `tmux send-keys -l` takes the literal payload as + // argv, which means it must be valid UTF-8 — we can't + // smuggle arbitrary bytes through. Silently + // `from_utf8_lossy`-replacing breaks the `Bytes` + // contract by mangling non-UTF-8 input into U+FFFD; + // surface the unsupported case as an explicit error + // instead so the caller sees the failure. + let s = std::str::from_utf8(&raw) + .map_err(|_| { + HostError::Other( + "tmux send-keys does not support non-UTF-8 binary input".into(), + ) + })? + .to_string(); + args.push("-l".into()); + args.push("--".into()); + args.push(s); + } + } + let st = crate::process::command("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 = crate::process::command("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 = crate::process::command("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 = crate::process::command("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 _ = crate::process::command("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 = crate::process::command("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. Propagate spawn + // / wait errors via `?`; previously a stray `let _ =` swallowed + // the `Result` so I/O failures here disappeared silently. + crate::process::command("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 + // 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 = crate::process::command("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 crate::process::std_command; + + /// 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 = std_command(&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 = std_command(&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); + } + + #[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 = std_command("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); + } +} diff --git a/src/agent/interactive_host/types.rs b/src/agent/interactive_host/types.rs new file mode 100644 index 000000000..4618a6605 --- /dev/null +++ b/src/agent/interactive_host/types.rs @@ -0,0 +1,66 @@ +//! Shared types used by both InteractiveHost implementations. + +use serde::{Deserialize, Serialize}; + +use crate::agent::interactive_protocol::HookFired; + +/// 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/interactive_protocol.rs b/src/agent/interactive_protocol.rs new file mode 100644 index 000000000..0e55c1277 --- /dev/null +++ b/src/agent/interactive_protocol.rs @@ -0,0 +1,380 @@ +//! 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; + +/// 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}; + + 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::*; + + 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); + } + + #[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)] +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}"); + } + + #[tokio::test] + async fn frame_rejects_truncated_header() { + let (mut a, mut b) = duplex(64 * 1024); + // Write only 2 bytes of the 4-byte length prefix, then close the + // writer so the reader observes EOF instead of hanging. + a.write_all(&[0u8, 0u8]).await.unwrap(); + a.shutdown().await.unwrap(); + let err = read_frame(&mut b).await.unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof, "got: {err}"); + } + + #[tokio::test] + async fn frame_rejects_partial_payload() { + let (mut a, mut b) = duplex(64 * 1024); + // Announce a 100-byte payload but only deliver 50 before closing. + let header = 100u32.to_be_bytes(); + a.write_all(&header).await.unwrap(); + a.write_all(&[0u8; 50]).await.unwrap(); + a.shutdown().await.unwrap(); + let err = read_frame(&mut b).await.unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof, "got: {err}"); + } + + #[tokio::test] + async fn frame_accepts_zero_length_payload() { + let (mut a, mut b) = duplex(64 * 1024); + // A zero-length payload is a valid frame: just the 4-byte length=0 + // header with no body. + a.write_all(&[0u8, 0u8, 0u8, 0u8]).await.unwrap(); + a.shutdown().await.unwrap(); + let buf = read_frame(&mut b).await.unwrap(); + assert_eq!(buf, Vec::::new()); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 35016caf3..56f22f45e 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,10 +1,13 @@ mod args; pub mod background; mod binary; +pub mod claude_interactive; 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")] pub mod pi_control; @@ -33,6 +36,10 @@ pub use harness::{ AgentHarnessCapabilities, AgentHarnessKind, AgentSession, ClaudeCodeHarness, PersistentSessionStart, }; +pub use interactive_host::{ + 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, }; diff --git a/src/agent_backend.rs b/src/agent_backend.rs index 62c9a9002..94abcac7f 100644 --- a/src/agent_backend.rs +++ b/src/agent_backend.rs @@ -99,6 +99,16 @@ impl AgentBackendKind { /// The harnesses the user is allowed to pick for a backend of this /// kind. The first entry is the default. Pinning a value not in /// this list is rejected by the resolver as defense-in-depth. + /// + /// `ClaudeInteractive` is intentionally **not** in this 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, the persistence command) + /// should use [`available_harnesses_with_interactive`] which + /// conditionally appends `ClaudeInteractive` for the Claude-flavored + /// kinds when the flag is on. All other call sites (Pi-disabled + /// downgrade, gateway-hash key) want the matrix shape and should + /// stick with this method. pub fn available_harnesses(self) -> &'static [AgentBackendRuntimeHarness] { match self { Self::Anthropic | Self::CustomAnthropic | Self::CodexSubscription => { @@ -129,6 +139,40 @@ impl AgentBackendKind { Self::PiSdk => &[AgentBackendRuntimeHarness::PiSdk], } } + + /// Like [`available_harnesses`] but conditionally appends + /// [`AgentBackendRuntimeHarness::ClaudeInteractive`] when the + /// experimental flag is on AND this kind is one of the + /// Claude-flavored ones (Anthropic, CustomAnthropic, + /// CodexSubscription). These three kinds are locked to the Claude + /// CLI runtime so subscription OAuth tokens never reach Pi — + /// interactive Claude is just another Claude-side harness, so it + /// belongs alongside `ClaudeCode` for exactly those kinds. Every + /// other kind ignores the flag and returns the static matrix + /// unchanged. + /// + /// Used by the Settings runtime picker (so the dropdown actually + /// lists the option when the flag is on) and by + /// `set_agent_backend_runtime_harness` (so the persistence + /// validator doesn't reject a sanctioned value). The static-slice + /// [`available_harnesses`] still gates the gateway-hash key and + /// the Pi-disabled downgrade because those flows want the matrix + /// shape, not the experimental gate. + pub fn available_harnesses_with_interactive( + self, + claude_interactive_enabled: bool, + ) -> Vec { + let mut harnesses: Vec = self.available_harnesses().to_vec(); + if claude_interactive_enabled + && matches!( + self, + Self::Anthropic | Self::CustomAnthropic | Self::CodexSubscription + ) + { + harnesses.push(AgentBackendRuntimeHarness::ClaudeInteractive); + } + harnesses + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -136,6 +180,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 +291,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 { @@ -682,6 +776,113 @@ mod tests { } } + #[test] + fn available_harnesses_with_interactive_omits_interactive_when_flag_off() { + // Flag off → identical to the static matrix shape, for every + // kind. This is the back-compat baseline that callers without + // the flag value (Pi-disabled downgrade, gateway-hash key) rely + // on implicitly. + let kinds = [ + AgentBackendKind::Anthropic, + AgentBackendKind::CustomAnthropic, + AgentBackendKind::CodexSubscription, + AgentBackendKind::Ollama, + AgentBackendKind::LmStudio, + AgentBackendKind::OpenAiApi, + AgentBackendKind::CustomOpenAi, + AgentBackendKind::CodexNative, + #[cfg(feature = "pi-sdk")] + AgentBackendKind::PiSdk, + ]; + for kind in kinds { + assert_eq!( + kind.available_harnesses_with_interactive(false), + kind.available_harnesses().to_vec(), + "{kind:?}::available_harnesses_with_interactive(false) must match the static matrix", + ); + assert!( + !kind + .available_harnesses_with_interactive(false) + .contains(&AgentBackendRuntimeHarness::ClaudeInteractive), + "{kind:?} must never expose ClaudeInteractive when the flag is off", + ); + } + } + + #[test] + fn available_harnesses_with_interactive_appends_for_claude_flavored_kinds() { + // Flag on → the three Claude-CLI-locked kinds gain + // ClaudeInteractive as a second option, appended after the + // existing ClaudeCode entry so the kind's default stays first. + for kind in [ + AgentBackendKind::Anthropic, + AgentBackendKind::CustomAnthropic, + AgentBackendKind::CodexSubscription, + ] { + let harnesses = kind.available_harnesses_with_interactive(true); + assert_eq!( + harnesses, + vec![ + AgentBackendRuntimeHarness::ClaudeCode, + AgentBackendRuntimeHarness::ClaudeInteractive, + ], + "{kind:?} should expose ClaudeCode then ClaudeInteractive when the flag is on", + ); + } + } + + #[test] + fn available_harnesses_with_interactive_skips_non_claude_kinds_even_with_flag() { + // ClaudeInteractive is a Claude-runtime variant — Pi / Ollama / + // LM Studio / OpenAI / CodexNative must never offer it, + // regardless of the flag. Otherwise the runtime picker would + // dangle an option the resolver can't honor (Pi can't host an + // interactive Claude session against user OAuth). + let kinds = [ + AgentBackendKind::Ollama, + AgentBackendKind::LmStudio, + AgentBackendKind::OpenAiApi, + AgentBackendKind::CustomOpenAi, + AgentBackendKind::CodexNative, + #[cfg(feature = "pi-sdk")] + AgentBackendKind::PiSdk, + ]; + for kind in kinds { + let harnesses = kind.available_harnesses_with_interactive(true); + assert!( + !harnesses.contains(&AgentBackendRuntimeHarness::ClaudeInteractive), + "{kind:?} must not expose ClaudeInteractive even when the flag is on", + ); + // And the rest of the list stays exactly the static matrix. + assert_eq!( + harnesses, + kind.available_harnesses().to_vec(), + "{kind:?}::available_harnesses_with_interactive(true) must match the static matrix", + ); + } + } + + #[test] + fn effective_harness_kind_round_trips_claude_interactive_override() { + // Full round-trip: user picks ClaudeInteractive (which the + // persistence validator allows only when the flag is on), the + // override is persisted as `runtime_harness = + // Some(ClaudeInteractive)`, and `effective_harness_kind(true)` + // dispatches to the interactive harness. Flipping the flag off + // falls back to the kind's default — defense-in-depth against a + // stale override surviving a downgrade. + let mut backend = AgentBackendConfig::builtin_anthropic(); + backend.runtime_harness = Some(AgentBackendRuntimeHarness::ClaudeInteractive); + assert_eq!( + backend.effective_harness_kind(true), + crate::agent::AgentHarnessKind::ClaudeInteractive, + ); + assert_eq!( + backend.effective_harness_kind(false), + crate::agent::AgentHarnessKind::ClaudeCode, + ); + } + #[cfg(feature = "pi-sdk")] #[test] fn pi_sdk_kind_locked_to_pi_harness() { @@ -749,6 +950,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", } diff --git a/src/db/interactive_sessions.rs b/src/db/interactive_sessions.rs new file mode 100644 index 000000000..144ee2d5f --- /dev/null +++ b/src/db/interactive_sessions.rs @@ -0,0 +1,365 @@ +//! 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() + } + + /// Return every `interactive_sessions.sid` across all workspaces + /// regardless of state. Used by the startup orphan detector + /// (`claudette::interactive::detect_orphans`) which compares the DB + /// set against the host's live session list to find sids the host + /// has but the DB doesn't. + /// + /// We intentionally project to just the sid column instead of full + /// rows — orphan detection only needs the identifier, and a tiny + /// snapshot keeps the boot path cheap. + pub fn list_all_interactive_session_sids(&self) -> rusqlite::Result> { + let mut stmt = self.conn.prepare("SELECT sid FROM interactive_sessions")?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + rows.collect() + } + + /// Return every `interactive_sessions` row currently in `state = + /// 'running'`, across all workspaces. Used by the startup + /// reconciler (`claudette::interactive::reattach_pending`) to find + /// rows that need to be reclassified against the live host. + pub fn list_running_interactive_sessions( + &self, + ) -> 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 state = 'running' + ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([], |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<()> { + 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(()) + } + + pub fn update_interactive_session_screen( + &self, + sid: &str, + blob: &[u8], + ) -> rusqlite::Result<()> { + 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(()) + } + + 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), + } + } + + 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"); + + 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 31dbd01dd..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; @@ -918,6 +921,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/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 { diff --git a/src/interactive.rs b/src/interactive.rs new file mode 100644 index 000000000..288a8ec58 --- /dev/null +++ b/src/interactive.rs @@ -0,0 +1,1448 @@ +//! Startup lifecycle helpers for interactive (long-lived `claude` TUI) sessions. +//! +//! Claudette persists one row per interactive `claude` session in the +//! `interactive_sessions` table, with a `state` column that tracks +//! whether the session is live (`running`), idle but reattachable +//! (`detached`), or gone (`crashed` / `stopped`). The transitions +//! between those states normally run while Claudette is up and +//! talking to its `InteractiveHost`. But when Claudette itself crashes +//! or is force-quit, any rows that were last seen as `running` are +//! frozen in that state — the GUI restarts with stale "active" badges +//! pointing at sessions that may or may not still exist on the host. +//! +//! [`reattach_pending`] is the boot-time reconciler that walks every +//! `running` row, asks the host what it actually has, and rewrites the +//! row's state accordingly: +//! +//! - Host still has the session → `state = 'detached'`. The host is +//! alive and the session can be reattached by the user; the +//! sidebar badge (G7) shows a "detached" pip instead of a green +//! "running" one. +//! - Host doesn't have the session → `state = 'crashed'`, +//! `crash_reason = "host missing"`. The session is unrecoverable; +//! the row stays in the listing so the user can clear it. +//! +//! Rows already in `detached`, `crashed`, `stopped`, etc. are left +//! alone — the reconciliation is intentionally scoped to "the set of +//! rows whose state can be wrong after a Claudette restart". +//! +//! This is a pure function over a `Database` and an `InteractiveHost` +//! so it can be unit-tested without booting Tauri. The Tauri startup +//! code calls it once per workspace's resolved host after the DB is +//! opened and before the UI is allowed to render the affected +//! sessions. +//! +//! Note: H1 does NOT auto-reattach the running session into the live +//! UI. That requires coordination with the chat panel and turn +//! assembler and lives in a follow-up task; H1's job is just to +//! classify the persisted rows so the badge in the sidebar reflects +//! the truth. + +use crate::agent::interactive_host::{InteractiveHost, SessionId}; +use crate::db::{Database, InteractiveSessionRow}; + +/// Reconcile every `running` `interactive_sessions` row against +/// `host`. See module docs for the full transition table. +/// +/// Errors from the host's `status()` surface to the caller — there is +/// no sensible way to classify rows without knowing what the host has, +/// so we don't paper over the failure. Per-row DB write errors are +/// logged at WARN and skipped so a single bad row can't poison the +/// rest of the reconciliation. +/// +/// The function takes `&Database` and the returned future is not +/// `Send`-friendly because `rusqlite::Connection` is `!Sync`. Callers +/// that need to drive the future from a multi-thread Tokio runtime +/// must `spawn_local` (or hand the work to a `LocalSet` / +/// `spawn_blocking` + `current_thread` runtime). The Tauri-side +/// wiring in `claudette-tauri/src/interactive_lifecycle.rs` handles +/// that for the boot path. +#[tracing::instrument(level = "info", target = "claudette::interactive", skip_all)] +pub async fn reattach_pending( + db: &Database, + host: &dyn InteractiveHost, +) -> Result<(), ReattachError> { + let pending = db + .list_running_interactive_sessions() + .map_err(ReattachError::Db)?; + reattach_rows(db, &pending, host).await +} + +/// Reconcile a caller-supplied set of `interactive_sessions` rows +/// against `host`. Identical contract to [`reattach_pending`], but the +/// row list is provided by the caller instead of being queried from +/// the DB — useful when the caller has already fetched the rows +/// (typically to avoid opening multiple `Database` connections). +/// +/// The caller is responsible for scoping `rows` to rows that should +/// be reconciled. In practice every entry should currently be in +/// `state = 'running'`; rows in other states will still be rewritten, +/// so don't pass them in unless that's what you want. +/// +/// When `rows` is empty this returns `Ok(())` without calling +/// `host.status()` — matches the no-op fast path in `reattach_pending` +/// and means callers can pass an empty workspace group cheaply. +#[tracing::instrument(level = "info", target = "claudette::interactive", skip_all)] +pub async fn reattach_rows( + db: &Database, + rows: &[InteractiveSessionRow], + host: &dyn InteractiveHost, +) -> Result<(), ReattachError> { + if rows.is_empty() { + return Ok(()); + } + + let status = host.status().await.map_err(ReattachError::Host)?; + // Collect the host's live session ids into a quick-lookup set. The + // host can report sessions with `running = false` if it is + // tracking ones that exited but hasn't reaped yet; treat those as + // "host missing" too — the session is no longer attachable. + let alive: std::collections::HashSet<&str> = status + .sessions + .iter() + .filter(|s| s.running) + .map(|s| s.sid.as_str()) + .collect(); + + for row in rows { + let sid = row.sid.as_str(); + let (next_state, crash_reason) = if alive.contains(sid) { + ("detached", None) + } else { + ("crashed", Some("host missing")) + }; + if let Err(err) = db.set_interactive_session_state(sid, next_state, crash_reason) { + tracing::warn!( + target: "claudette::interactive", + sid = %sid, + error = %err, + next_state, + "failed to reclassify interactive session on startup; skipping", + ); + } + } + + Ok(()) +} + +/// Compute the set of `claudette-` sessions the host knows about but +/// the DB does NOT — i.e. sessions left over from a previous Claudette +/// process that crashed before it could record them (or before it +/// could mark a now-orphaned row as torn down). These are returned to +/// the caller so the UI can surface a one-shot "Clean up" prompt and +/// invoke [`InteractiveHost::stop`] on each via the Tauri +/// `interactive_cleanup_orphans` command. +/// +/// The filter is intentionally narrow: only sessions whose sid starts +/// with the `claudette-` prefix are considered. The host may be +/// hosting unrelated tmux sessions for the user (or unrelated sidecar +/// sessions from another tool sharing the socket), and we must never +/// stop those. +/// +/// `db_known_sids` is the full set of sids the DB currently tracks for +/// this host — typically the union of every state. The caller is +/// responsible for assembling this list (usually by snapshotting all +/// `interactive_sessions` rows). Passing only `running` sids would +/// incorrectly flag valid `detached` / `crashed` rows as orphans. +/// +/// Errors from `host.status()` surface to the caller — there is no +/// safe default behavior when the host is unreachable (we don't want +/// to claim "no orphans" if we couldn't actually look). +#[tracing::instrument(level = "info", target = "claudette::interactive", skip_all)] +pub async fn detect_orphans( + db_known_sids: &[String], + host: &dyn InteractiveHost, +) -> Result, ReattachError> { + let status = host.status().await.map_err(ReattachError::Host)?; + let db_set: std::collections::HashSet<&str> = + db_known_sids.iter().map(|s| s.as_str()).collect(); + let orphans = status + .sessions + .iter() + .filter(|s| { + s.running + && s.sid.as_str().starts_with("claudette-") + && !db_set.contains(s.sid.as_str()) + }) + .map(|s| s.sid.clone()) + .collect(); + Ok(orphans) +} + +/// Errors returned by [`reattach_pending`]. +#[derive(Debug, thiserror::Error)] +pub enum ReattachError { + /// Reading the list of pending sessions failed. + #[error("database error: {0}")] + Db(rusqlite::Error), + /// Calling `status()` on the host failed. + #[error("host error: {0}")] + Host(#[from] crate::agent::interactive_host::HostError), +} + +/// Gracefully stop every interactive session in `rows` against `host`. +/// +/// Used by the workspace archive / delete code paths to tear down the +/// HOST side of an interactive session BEFORE the `interactive_sessions` +/// row is removed by `ON DELETE CASCADE`. Without this, a workspace +/// delete would orphan a live tmux session / sidecar tab — Claudette +/// would no longer track it, but it would keep running. +/// +/// Failures from individual `host.stop` calls are logged at WARN and +/// skipped. We do NOT propagate them: the user-facing operation +/// (archive / delete) must still complete even if one host session +/// can't be reached (e.g. tmux server already gone, sidecar socket +/// closed). Cascading the failure would leave the user unable to clear +/// a workspace whose host has already crashed, which is exactly the +/// case where they need the cleanup most. +/// +/// Pure function over a host trait so it can be unit-tested without +/// booting Tauri — see the `stop_sessions_calls_host_stop_for_each` +/// test below for the MockHost pattern. +#[tracing::instrument(level = "info", target = "claudette::interactive", skip_all)] +pub async fn stop_sessions_for_workspace( + rows: &[crate::db::InteractiveSessionRow], + host: &dyn crate::agent::interactive_host::InteractiveHost, +) { + use crate::agent::interactive_host::SessionId; + use crate::agent::interactive_protocol::StopMode; + + for row in rows { + // Skip rows we already know are dead — there's nothing on the + // host side to stop and the next-state DB cascade will clean + // them up. "crashed" / "stopped" rows still get the cascade. + if row.state == "crashed" || row.state == "stopped" { + continue; + } + let sid = SessionId(row.sid.clone()); + if let Err(err) = host.stop(&sid, StopMode::Graceful).await { + tracing::warn!( + target: "claudette::interactive", + sid = %row.sid, + workspace_id = %row.workspace_id, + state = %row.state, + error = %err, + "failed to gracefully stop interactive session during workspace teardown; \ + continuing so workspace delete/archive can proceed", + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::interactive_host::{ + AttachId, AttachStream, HostError, HostHandle, HostSessionSummary, HostStatus, + InteractiveHost, ScreenSnapshot, SessionId, + }; + use crate::agent::interactive_protocol::{InputPayload, SessionSpec, StopMode}; + use crate::db::test_support::{make_repo, make_workspace}; + use crate::db::{Database, InteractiveSessionRow}; + use async_trait::async_trait; + + fn insert_test_workspace(db: &Database, ws_id: &str) { + // The interactive_sessions table FKs into workspaces(id), so the + // test row needs a real workspace — and every workspace requires + // an owning repository. Seed both via the shared test_support + // helpers so the fixture stays in lockstep with the production + // schema. + db.insert_repository(&make_repo("repo-1", "/tmp/repo1", "repo-1")) + .ok(); // ignore duplicate-repo error on re-seed + db.insert_workspace(&make_workspace(ws_id, "repo-1", "fix-bug")) + .unwrap(); + } + + fn make_row(sid: &str, ws_id: &str, state: &str) -> InteractiveSessionRow { + InteractiveSessionRow { + sid: sid.into(), + workspace_id: ws_id.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, + } + } + + /// Mock host whose only meaningful method is `status()`. Every + /// other trait method panics — `reattach_pending` must never call + /// them, and a regression that does call them should fail loudly + /// rather than silently corrupt state. + struct MockHost { + status_response: HostStatus, + } + + #[async_trait] + impl InteractiveHost for MockHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!("reattach_pending must not call ensure_session") + } + async fn attach(&self, _sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!("reattach_pending must not call attach") + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!("reattach_pending must not call send_input") + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!("reattach_pending must not call capture_screen") + } + async fn resize(&self, _sid: &SessionId, _rows: u16, _cols: u16) -> Result<(), HostError> { + unimplemented!("reattach_pending must not call resize") + } + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + unimplemented!("reattach_pending must not call detach") + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unimplemented!("reattach_pending must not call stop") + } + async fn status(&self) -> Result { + Ok(self.status_response.clone()) + } + } + + #[tokio::test] + async fn reattach_on_startup_classifies_rows() { + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + + // Seed three rows: two in "running" (one of which the host + // still has, one it doesn't), and one already "detached" that + // must be left untouched. + for (sid, state) in [ + ("claudette-ws1-aaaaaaaa", "running"), + ("claudette-ws1-bbbbbbbb", "running"), + ("claudette-ws1-cccccccc", "detached"), + ] { + db.create_interactive_session(&make_row(sid, "ws-1", state)) + .unwrap(); + } + + // Host knows about A and C only — B will be classified as + // crashed even though we don't include it in the host's list. + let host = 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, + }, + ], + }, + }; + + reattach_pending(&db, &host).await.unwrap(); + + let a = db + .get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .unwrap(); + assert_eq!(a.state, "detached", "A: still on host → detached"); + assert!(a.crash_reason.is_none()); + + let b = db + .get_interactive_session("claudette-ws1-bbbbbbbb") + .unwrap() + .unwrap(); + assert_eq!(b.state, "crashed", "B: missing on host → crashed"); + assert_eq!(b.crash_reason.as_deref(), Some("host missing")); + + let c = db + .get_interactive_session("claudette-ws1-cccccccc") + .unwrap() + .unwrap(); + assert_eq!(c.state, "detached", "C: already detached, left alone"); + assert!(c.crash_reason.is_none()); + } + + #[tokio::test] + async fn reattach_pending_treats_not_running_host_summaries_as_missing() { + // The host can report a session with `running = false` while it + // is mid-reap; those count as "host missing" for the purposes + // of the DB reconciliation because the user can no longer + // attach to them. + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + db.create_interactive_session(&make_row("claudette-ws1-zzzzzzzz", "ws-1", "running")) + .unwrap(); + + let host = MockHost { + status_response: HostStatus { + host_version: "mock".into(), + sessions: vec![HostSessionSummary { + sid: SessionId("claudette-ws1-zzzzzzzz".into()), + pid: None, + running: false, + }], + }, + }; + + reattach_pending(&db, &host).await.unwrap(); + + let row = db + .get_interactive_session("claudette-ws1-zzzzzzzz") + .unwrap() + .unwrap(); + assert_eq!(row.state, "crashed"); + assert_eq!(row.crash_reason.as_deref(), Some("host missing")); + } + + #[tokio::test] + async fn reattach_pending_is_noop_when_no_running_rows() { + // No DB rows means no host call: this contract matters because + // booting against a non-functional host (no tmux, sidecar + // binary missing) shouldn't error out the boot path when there + // is nothing to reconcile. + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + db.create_interactive_session(&make_row("claudette-ws1-stopped", "ws-1", "stopped")) + .unwrap(); + + struct FailingHost; + #[async_trait] + impl InteractiveHost for FailingHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!() + } + async fn attach( + &self, + _sid: &SessionId, + ) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!() + } + async fn resize( + &self, + _sid: &SessionId, + _rows: u16, + _cols: u16, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn detach( + &self, + _sid: &SessionId, + _attach_id: AttachId, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unimplemented!() + } + async fn status(&self) -> Result { + panic!("status must not be called when no running rows exist") + } + } + + reattach_pending(&db, &FailingHost).await.unwrap(); + + // Pre-existing non-running row is untouched. + let row = db + .get_interactive_session("claudette-ws1-stopped") + .unwrap() + .unwrap(); + assert_eq!(row.state, "stopped"); + } + + #[tokio::test] + async fn reattach_rows_classifies_provided_rows_without_db_query() { + // reattach_rows operates on caller-supplied rows; it must NOT + // call list_running_interactive_sessions itself. To prove that, + // seed the DB with a row that has state = 'detached' (so the + // list query would return zero "running" rows), pass that row + // in explicitly, and verify it still gets classified. + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + + // Seed two rows in 'detached' so list_running_interactive_sessions + // returns an empty list; pass them in to reattach_rows directly. + let row_a = make_row("claudette-ws1-aaaaaaaa", "ws-1", "detached"); + let row_b = make_row("claudette-ws1-bbbbbbbb", "ws-1", "detached"); + db.create_interactive_session(&row_a).unwrap(); + db.create_interactive_session(&row_b).unwrap(); + + let host = MockHost { + status_response: HostStatus { + host_version: "mock".into(), + sessions: vec![HostSessionSummary { + sid: SessionId("claudette-ws1-aaaaaaaa".into()), + pid: None, + running: true, + }], + }, + }; + + reattach_rows(&db, &[row_a, row_b], &host).await.unwrap(); + + // A was reported by host as running → detached (unchanged label, + // but the call path proves the row was processed). + let a = db + .get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .unwrap(); + assert_eq!(a.state, "detached"); + assert!(a.crash_reason.is_none()); + + // B is not on the host → crashed, host missing. + 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")); + } + + #[tokio::test] + async fn reattach_rows_is_noop_when_rows_empty() { + // Empty row slice must not call host.status(). This is the + // contract the boot reconciler relies on when a workspace + // group ends up empty: passing it in shouldn't spin up the + // sidecar. + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + + struct PanickyHost; + #[async_trait] + impl InteractiveHost for PanickyHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!() + } + async fn attach( + &self, + _sid: &SessionId, + ) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!() + } + async fn resize( + &self, + _sid: &SessionId, + _rows: u16, + _cols: u16, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn detach( + &self, + _sid: &SessionId, + _attach_id: AttachId, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unimplemented!() + } + async fn status(&self) -> Result { + panic!("status must not be called when rows is empty") + } + } + + reattach_rows(&db, &[], &PanickyHost).await.unwrap(); + } + + #[tokio::test] + async fn reattach_rows_partial_db_write_failure_isolates_to_one_row() { + // Contract: a per-row DB-write failure inside reattach_rows + // (e.g. the row vanished between `list_running_*` and the + // update, returning rusqlite::Error::QueryReturnedNoRows) must + // NOT abort the remaining iterations. The bad row is logged at + // WARN and skipped; every other row still reaches its expected + // terminal state. + // + // Construction: we pass `reattach_rows` two row values, but + // only persist the SECOND one in the DB. The first row's sid + // does not exist as a DB row, so `set_interactive_session_state` + // returns QueryReturnedNoRows for it. The surviving row must + // still be reclassified. + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + + // Only the survivor is persisted. The "ghost" row is passed + // into reattach_rows but never inserted, so its UPDATE matches + // zero rows and triggers QueryReturnedNoRows. + let ghost = make_row("claudette-ws1-ghostghost", "ws-1", "running"); + let survivor = make_row("claudette-ws1-survivor1", "ws-1", "running"); + db.create_interactive_session(&survivor).unwrap(); + + // Host knows about neither sid → both should be classified as + // "crashed / host missing". Ghost's UPDATE fails (no row), + // survivor's UPDATE succeeds. + let host = MockHost { + status_response: HostStatus { + host_version: "mock".into(), + sessions: vec![], + }, + }; + + // The function returns Ok(()) — per-row DB errors are + // swallowed, not propagated. + reattach_rows(&db, &[ghost, survivor], &host).await.unwrap(); + + // Surviving row reached the expected terminal state. + let row = db + .get_interactive_session("claudette-ws1-survivor1") + .unwrap() + .unwrap(); + assert_eq!( + row.state, "crashed", + "survivor must be classified even though an earlier row's UPDATE failed", + ); + assert_eq!(row.crash_reason.as_deref(), Some("host missing")); + + // Ghost still doesn't exist — the failed UPDATE didn't create it. + assert!( + db.get_interactive_session("claudette-ws1-ghostghost") + .unwrap() + .is_none(), + "ghost row must not be resurrected by a failed UPDATE", + ); + } + + #[tokio::test] + async fn reattach_rows_partial_db_write_failure_processes_later_rows() { + // Complementary to the previous test: put the FAILING row in + // the MIDDLE of a three-row batch and verify both the + // before-failure row AND the after-failure row reach the + // expected terminal state. This pins down the "skip, don't + // abort" semantics: if reattach_rows used `?` on the DB write, + // the third row would never be touched. + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + + let before = make_row("claudette-ws1-beforeone", "ws-1", "running"); + let ghost = make_row("claudette-ws1-ghostmid1", "ws-1", "running"); + let after = make_row("claudette-ws1-afterone1", "ws-1", "running"); + db.create_interactive_session(&before).unwrap(); + db.create_interactive_session(&after).unwrap(); + // `ghost` intentionally NOT inserted, so its UPDATE will match + // zero rows and trigger QueryReturnedNoRows mid-loop. + + // Host reports `before` as alive → "detached"; nothing else + // claimed alive → ghost + after classified as "crashed". + let host = MockHost { + status_response: HostStatus { + host_version: "mock".into(), + sessions: vec![HostSessionSummary { + sid: SessionId("claudette-ws1-beforeone".into()), + pid: None, + running: true, + }], + }, + }; + + reattach_rows(&db, &[before, ghost, after.clone()], &host) + .await + .unwrap(); + + let before_row = db + .get_interactive_session("claudette-ws1-beforeone") + .unwrap() + .unwrap(); + assert_eq!( + before_row.state, "detached", + "row before the failing write must still be classified", + ); + + let after_row = db.get_interactive_session(&after.sid).unwrap().unwrap(); + assert_eq!( + after_row.state, "crashed", + "row after the failing write must still be classified — \ + a failure in the middle of the loop must not short-circuit it", + ); + assert_eq!(after_row.crash_reason.as_deref(), Some("host missing")); + } + + #[tokio::test] + async fn reattach_rows_surfaces_host_status_errors() { + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + let row = make_row("claudette-ws1-aaaaaaaa", "ws-1", "running"); + db.create_interactive_session(&row).unwrap(); + + struct ErroringHost; + #[async_trait] + impl InteractiveHost for ErroringHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!() + } + async fn attach( + &self, + _sid: &SessionId, + ) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!() + } + async fn resize( + &self, + _sid: &SessionId, + _rows: u16, + _cols: u16, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn detach( + &self, + _sid: &SessionId, + _attach_id: AttachId, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unimplemented!() + } + async fn status(&self) -> Result { + Err(HostError::Unavailable("mock".into())) + } + } + + let err = reattach_rows(&db, &[row], &ErroringHost).await.unwrap_err(); + assert!( + matches!(err, ReattachError::Host(_)), + "expected ReattachError::Host, got {err:?}", + ); + + // Row is untouched — we don't reclassify when we couldn't + // reach the host to ask. + let persisted = db + .get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .unwrap(); + assert_eq!(persisted.state, "running"); + } + + #[tokio::test] + async fn reattach_pending_surfaces_host_status_errors() { + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + db.create_interactive_session(&make_row("claudette-ws1-aaaaaaaa", "ws-1", "running")) + .unwrap(); + + struct ErroringHost; + #[async_trait] + impl InteractiveHost for ErroringHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!() + } + async fn attach( + &self, + _sid: &SessionId, + ) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!() + } + async fn resize( + &self, + _sid: &SessionId, + _rows: u16, + _cols: u16, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn detach( + &self, + _sid: &SessionId, + _attach_id: AttachId, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unimplemented!() + } + async fn status(&self) -> Result { + Err(HostError::Unavailable("mock".into())) + } + } + + let err = reattach_pending(&db, &ErroringHost).await.unwrap_err(); + assert!( + matches!(err, ReattachError::Host(_)), + "expected ReattachError::Host, got {err:?}", + ); + + // Row is untouched — we don't reclassify when we couldn't + // reach the host to ask. + let row = db + .get_interactive_session("claudette-ws1-aaaaaaaa") + .unwrap() + .unwrap(); + assert_eq!(row.state, "running"); + } + + // ----- detect_orphans -------------------------------------------------- + // + // Orphan detection is a pure host.status() consumer; we reuse MockHost + // from the reattach tests to construct a known status response. + + #[tokio::test] + async fn detect_orphans_returns_host_sessions_missing_from_db() { + // DB tracks A; host has A + B + C (all claudette- prefixed). + // → B and C are orphans. + let host = 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-bbbbbbbb".into()), + pid: None, + running: true, + }, + HostSessionSummary { + sid: SessionId("claudette-ws1-cccccccc".into()), + pid: None, + running: true, + }, + ], + }, + }; + + let db_known = vec!["claudette-ws1-aaaaaaaa".to_string()]; + let orphans = detect_orphans(&db_known, &host).await.unwrap(); + + let orphan_strs: Vec<&str> = orphans.iter().map(|s| s.as_str()).collect(); + assert_eq!( + orphan_strs.len(), + 2, + "expected exactly two orphans, got {orphan_strs:?}", + ); + assert!(orphan_strs.contains(&"claudette-ws1-bbbbbbbb")); + assert!(orphan_strs.contains(&"claudette-ws1-cccccccc")); + assert!( + !orphan_strs.contains(&"claudette-ws1-aaaaaaaa"), + "DB-known sid must not be reported as orphan", + ); + } + + #[tokio::test] + async fn detect_orphans_ignores_non_claudette_prefixed_sessions() { + // Host hosts a user-owned tmux session ("dev", "scratch") AND a + // legit DB-tracked claudette session. None of those user sessions + // count as orphans — Claudette must never kill a session it + // didn't create. + let host = MockHost { + status_response: HostStatus { + host_version: "mock".into(), + sessions: vec![ + HostSessionSummary { + sid: SessionId("dev".into()), + pid: None, + running: true, + }, + HostSessionSummary { + sid: SessionId("scratch".into()), + pid: None, + running: true, + }, + HostSessionSummary { + sid: SessionId("claudette-ws1-aaaaaaaa".into()), + pid: None, + running: true, + }, + ], + }, + }; + + let db_known = vec!["claudette-ws1-aaaaaaaa".to_string()]; + let orphans = detect_orphans(&db_known, &host).await.unwrap(); + + assert!( + orphans.is_empty(), + "non-claudette sessions must never appear as orphans; got {:?}", + orphans.iter().map(|s| s.as_str()).collect::>(), + ); + } + + #[tokio::test] + async fn detect_orphans_with_empty_db_returns_all_claudette_sessions() { + // Cold start with a stale tmux server: DB has nothing yet, every + // claudette- session on the host is an orphan. + let host = 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-bbbbbbbb".into()), + pid: None, + running: true, + }, + ], + }, + }; + + let orphans = detect_orphans(&[], &host).await.unwrap(); + let orphan_strs: Vec<&str> = orphans.iter().map(|s| s.as_str()).collect(); + assert_eq!(orphan_strs.len(), 2); + assert!(orphan_strs.contains(&"claudette-ws1-aaaaaaaa")); + assert!(orphan_strs.contains(&"claudette-ws1-bbbbbbbb")); + } + + #[tokio::test] + async fn detect_orphans_returns_empty_when_db_covers_host() { + // Every host session is tracked → no orphans. + let host = 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-bbbbbbbb".into()), + pid: None, + running: true, + }, + ], + }, + }; + + let db_known = vec![ + "claudette-ws1-aaaaaaaa".to_string(), + "claudette-ws1-bbbbbbbb".to_string(), + ]; + let orphans = detect_orphans(&db_known, &host).await.unwrap(); + assert!(orphans.is_empty()); + } + + #[tokio::test] + async fn detect_orphans_excludes_not_running_host_summaries() { + // Host reports two claudette- sessions: one with running=true + // (orphan candidate) and one with running=false (mid-reap or + // graceful shutdown — must NOT be flagged as an orphan). + // The DB knows about neither. Only the running=true session + // should be returned, mirroring the + // `reattach_pending_treats_not_running_host_summaries_as_missing` + // semantics: not-running host summaries are not actionable. + let host = 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-bbbbbbbb".into()), + pid: None, + running: false, + }, + ], + }, + }; + + let orphans = detect_orphans(&[], &host).await.unwrap(); + let orphan_strs: Vec<&str> = orphans.iter().map(|s| s.as_str()).collect(); + assert_eq!( + orphan_strs, + vec!["claudette-ws1-aaaaaaaa"], + "only running host summaries should be reported as orphans", + ); + assert!( + !orphan_strs.contains(&"claudette-ws1-bbbbbbbb"), + "not-running host summary must be excluded from orphan list", + ); + } + + #[tokio::test] + async fn detect_orphans_surfaces_host_status_errors() { + struct ErroringHost; + #[async_trait] + impl InteractiveHost for ErroringHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!() + } + async fn attach( + &self, + _sid: &SessionId, + ) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!() + } + async fn resize( + &self, + _sid: &SessionId, + _rows: u16, + _cols: u16, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn detach( + &self, + _sid: &SessionId, + _attach_id: AttachId, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unimplemented!() + } + async fn status(&self) -> Result { + Err(HostError::Unavailable("mock".into())) + } + } + + let err = detect_orphans(&[], &ErroringHost).await.unwrap_err(); + assert!( + matches!(err, ReattachError::Host(_)), + "expected ReattachError::Host, got {err:?}", + ); + } + + #[tokio::test] + async fn reattach_pending_skips_host_status_when_no_running_rows_in_db() { + // Complement of `reattach_rows_is_noop_when_rows_empty`: prove + // that `reattach_pending` (the variant that queries the DB + // itself) also short-circuits before touching `host.status()` + // when the `list_running_interactive_sessions()` query returns + // an empty list. Boot-time reconciliation against a workspace + // with zero pending rows must not spin up the host. + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + // Intentionally insert NO interactive_sessions rows — the + // running-list query will return empty. + + struct PanickyHost; + #[async_trait] + impl InteractiveHost for PanickyHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!() + } + async fn attach( + &self, + _sid: &SessionId, + ) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!() + } + async fn resize( + &self, + _sid: &SessionId, + _rows: u16, + _cols: u16, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn detach( + &self, + _sid: &SessionId, + _attach_id: AttachId, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + unimplemented!() + } + async fn status(&self) -> Result { + panic!("status must not be called when running list is empty") + } + } + + reattach_pending(&db, &PanickyHost).await.unwrap(); + } + + #[tokio::test] + async fn detect_orphans_returns_empty_when_host_status_has_no_sessions() { + // Mirror image of the cold-start case: the host's `status()` + // returns an empty `sessions` list (e.g. fresh tmux server with + // nothing on it yet). Even if the DB tracks several sids, the + // orphan set must be empty — there are no host sessions to + // classify as orphans. + let host = MockHost { + status_response: HostStatus { + host_version: "mock".into(), + sessions: vec![], + }, + }; + + let db_known = vec![ + "claudette-ws1-aaaaaaaa".to_string(), + "claudette-ws1-bbbbbbbb".to_string(), + ]; + let orphans = detect_orphans(&db_known, &host).await.unwrap(); + assert!( + orphans.is_empty(), + "empty host status must yield empty orphan list; got {:?}", + orphans.iter().map(|s| s.as_str()).collect::>(), + ); + } + + // ----- stop_sessions_for_workspace ----------------------------------- + // + // The MockHost below tracks every `stop` call (sid + mode) so the + // tests can assert exact graceful-shutdown ordering. Every other + // trait method panics because workspace teardown should never need + // to attach, send input, capture screen, etc. — only `stop` is + // valid on this path. + + struct StopTrackingHost { + stops: Mutex>, + /// When `Some(sid)`, the matching `stop` call returns Err + /// instead of Ok, so we can assert that one failure doesn't + /// poison the rest of the batch. + fail_for_sid: Option, + } + + use std::sync::Mutex; + + impl StopTrackingHost { + fn new() -> Self { + Self { + stops: Mutex::new(Vec::new()), + fail_for_sid: None, + } + } + + fn with_failure_for(sid: &str) -> Self { + Self { + stops: Mutex::new(Vec::new()), + fail_for_sid: Some(sid.into()), + } + } + + fn stopped_sids(&self) -> Vec { + self.stops + .lock() + .unwrap() + .iter() + .map(|(s, _)| s.clone()) + .collect() + } + } + + #[async_trait] + impl InteractiveHost for StopTrackingHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!("stop_sessions_for_workspace must not call ensure_session") + } + async fn attach(&self, _sid: &SessionId) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!("stop_sessions_for_workspace must not call attach") + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!("stop_sessions_for_workspace must not call send_input") + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!("stop_sessions_for_workspace must not call capture_screen") + } + async fn resize(&self, _sid: &SessionId, _rows: u16, _cols: u16) -> Result<(), HostError> { + unimplemented!("stop_sessions_for_workspace must not call resize") + } + async fn detach(&self, _sid: &SessionId, _attach_id: AttachId) -> Result<(), HostError> { + unimplemented!("stop_sessions_for_workspace must not call detach") + } + async fn stop(&self, sid: &SessionId, mode: StopMode) -> Result<(), HostError> { + self.stops.lock().unwrap().push((sid.0.clone(), mode)); + if let Some(ref fail) = self.fail_for_sid + && fail == &sid.0 + { + return Err(HostError::Unavailable("mock stop failure".into())); + } + Ok(()) + } + async fn status(&self) -> Result { + unimplemented!("stop_sessions_for_workspace must not call status") + } + } + + #[tokio::test] + async fn stop_sessions_calls_host_stop_for_each_live_row() { + let host = StopTrackingHost::new(); + let rows = vec![ + make_row("claudette-ws1-aaaaaaaa", "ws-1", "running"), + make_row("claudette-ws1-bbbbbbbb", "ws-1", "detached"), + ]; + + stop_sessions_for_workspace(&rows, &host).await; + + let stops = host.stops.lock().unwrap().clone(); + assert_eq!(stops.len(), 2, "both running+detached rows must be stopped"); + assert_eq!(stops[0].0, "claudette-ws1-aaaaaaaa"); + assert_eq!(stops[0].1, StopMode::Graceful); + assert_eq!(stops[1].0, "claudette-ws1-bbbbbbbb"); + assert_eq!(stops[1].1, StopMode::Graceful); + } + + #[tokio::test] + async fn stop_sessions_skips_already_dead_rows() { + // Sessions in `crashed` or `stopped` have no live host + // counterpart; calling stop on them risks a NotFound from the + // host. The DB cascade still removes the row when the workspace + // goes away. + let host = StopTrackingHost::new(); + let rows = vec![ + make_row("claudette-ws1-crashed1", "ws-1", "crashed"), + make_row("claudette-ws1-stopped1", "ws-1", "stopped"), + make_row("claudette-ws1-runninga", "ws-1", "running"), + ]; + + stop_sessions_for_workspace(&rows, &host).await; + + let stopped = host.stopped_sids(); + assert_eq!( + stopped, + vec!["claudette-ws1-runninga".to_string()], + "only the running row should reach host.stop", + ); + } + + #[tokio::test] + async fn stop_sessions_continues_after_per_row_host_failure() { + // The user-facing operation (archive / delete) must still + // complete when one host.stop call errors out, so the batch + // keeps going and the surviving sessions get their graceful + // shutdown. + let host = StopTrackingHost::with_failure_for("claudette-ws1-bbbbbbbb"); + let rows = vec![ + make_row("claudette-ws1-aaaaaaaa", "ws-1", "running"), + make_row("claudette-ws1-bbbbbbbb", "ws-1", "running"), + make_row("claudette-ws1-cccccccc", "ws-1", "detached"), + ]; + + stop_sessions_for_workspace(&rows, &host).await; + + let stopped = host.stopped_sids(); + assert_eq!( + stopped, + vec![ + "claudette-ws1-aaaaaaaa".to_string(), + "claudette-ws1-bbbbbbbb".to_string(), + "claudette-ws1-cccccccc".to_string(), + ], + "host.stop must be attempted for every live row even when one fails", + ); + } + + #[tokio::test] + async fn stop_sessions_is_noop_for_empty_rows() { + // Empty row slice must not call any host method. Mirrors the + // boot-reconciler contract: workspaces with no interactive + // sessions shouldn't spin up the host at teardown time. + struct PanickyHost; + #[async_trait] + impl InteractiveHost for PanickyHost { + async fn ensure_session( + &self, + _sid: &SessionId, + _spec: &SessionSpec, + ) -> Result { + unimplemented!() + } + async fn attach( + &self, + _sid: &SessionId, + ) -> Result<(AttachId, AttachStream), HostError> { + unimplemented!() + } + async fn send_input( + &self, + _sid: &SessionId, + _payload: InputPayload, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn capture_screen(&self, _sid: &SessionId) -> Result { + unimplemented!() + } + async fn resize( + &self, + _sid: &SessionId, + _rows: u16, + _cols: u16, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn detach( + &self, + _sid: &SessionId, + _attach_id: AttachId, + ) -> Result<(), HostError> { + unimplemented!() + } + async fn stop(&self, _sid: &SessionId, _mode: StopMode) -> Result<(), HostError> { + panic!("stop must not be called when rows is empty") + } + async fn status(&self) -> Result { + unimplemented!() + } + } + + stop_sessions_for_workspace(&[], &PanickyHost).await; + } + + // ----- End-to-end: workspace delete tears down interactive sessions ---- + // + // This is the H2 acceptance test from the plan: insert an + // interactive_sessions row, call the workspace teardown helper, and + // assert (a) host.stop was called for the session and (b) the row is + // gone from the DB after the cascade. We exercise the lib-level + // helper plus a manual cascade-trigger (`delete_workspace_with_summary`) + // rather than the Tauri command, since the lib crate cannot depend + // on the Tauri command surface. + + #[tokio::test] + async fn workspace_delete_stops_sessions_then_cascade_removes_row() { + let db = Database::open_in_memory().unwrap(); + insert_test_workspace(&db, "ws-1"); + + // Seed two live interactive sessions and one crashed one. After + // workspace teardown we expect: + // - host.stop called for the two live sessions, NOT the crashed one + // - all three rows gone from the DB (cascade removes everything) + db.create_interactive_session(&make_row("claudette-ws1-running1", "ws-1", "running")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-ws1-detach1", "ws-1", "detached")) + .unwrap(); + db.create_interactive_session(&make_row("claudette-ws1-crashe1", "ws-1", "crashed")) + .unwrap(); + + // Step 1: enumerate sessions, stop hosts (mimics the command path). + let rows = db.list_interactive_sessions_for_workspace("ws-1").unwrap(); + assert_eq!(rows.len(), 3, "all three rows present before teardown"); + + let host = StopTrackingHost::new(); + stop_sessions_for_workspace(&rows, &host).await; + + let stopped = host.stopped_sids(); + // Order is created_at DESC from the list query, so most-recently + // created comes first. The fixture sets created_at to the same + // value, so we just assert membership. + assert_eq!(stopped.len(), 2, "two live sessions stopped"); + assert!(stopped.contains(&"claudette-ws1-running1".to_string())); + assert!(stopped.contains(&"claudette-ws1-detach1".to_string())); + assert!( + !stopped.contains(&"claudette-ws1-crashe1".to_string()), + "crashed row must not trigger host.stop", + ); + + // Step 2: cascade-delete the workspace. + db.delete_workspace_with_summary("ws-1").unwrap(); + + // The interactive_sessions rows must be gone via FK ON DELETE + // CASCADE — proves the host stop happened BEFORE the cascade + // (otherwise we'd have no rows to enumerate above). + for sid in [ + "claudette-ws1-running1", + "claudette-ws1-detach1", + "claudette-ws1-crashe1", + ] { + assert!( + db.get_interactive_session(sid).unwrap().is_none(), + "row {sid} should be cascade-deleted with the workspace", + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 46c9a8822..8fa67ec36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub mod git; pub mod global_prompt; pub mod grammar_provider; pub mod i18n; +pub mod interactive; pub mod logging; pub mod mcp; pub mod mcp_supervisor; 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"), diff --git a/src/ui/.gitignore b/src/ui/.gitignore index a547bf36d..de9ee66e8 100644 --- a/src/ui/.gitignore +++ b/src/ui/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +coverage # Editor directories and files .vscode/* diff --git a/src/ui/bun.lock b/src/ui/bun.lock index e255a8fd2..6cacc78c2 100644 --- a/src/ui/bun.lock +++ b/src/ui/bun.lock @@ -47,6 +47,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-istanbul": "^4.1.5", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -146,6 +147,8 @@ "@iconify/utils": ["@iconify/utils@3.1.1", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.2" } }, "sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw=="], + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -382,6 +385,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@vitest/coverage-istanbul": ["@vitest/coverage-istanbul@4.1.5", "", { "dependencies": { "@babel/core": "^7.29.0", "@istanbuljs/schema": "^0.1.3", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/trace-mapping": "0.3.31", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "vitest": "4.1.5" } }, "sha512-X4kQMDEWh9mA0IiLuigtdYv4kXe+W8KLTbucoz15lbyZRPAxT5l+hu0JizI7Am050+G9vQnB7QJNgYi2LnwV4w=="], + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], @@ -676,6 +681,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -716,6 +723,12 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -782,6 +795,10 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], @@ -1166,6 +1183,10 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "magicast/@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + + "make-dir/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], diff --git a/src/ui/package.json b/src/ui/package.json index 0e4bec3e5..439d130de 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -12,7 +12,8 @@ "preview": "vite preview", "smoke:bundle": "node scripts/smoke-built-bundle.mjs", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:coverage:interactive": "vitest run --coverage" }, "dependencies": { "@monaco-editor/react": "^4.7.0", @@ -57,6 +58,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-istanbul": "^4.1.5", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/src/ui/src/App.orphans.test.tsx b/src/ui/src/App.orphans.test.tsx new file mode 100644 index 000000000..9228744c5 --- /dev/null +++ b/src/ui/src/App.orphans.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment happy-dom + +// Coverage for the App-level orphan-detected listener wiring. +// +// `App.tsx` is a god file (1000+ lines, dozens of providers / store +// reads / Tauri listeners). Mounting `` directly would require +// stubbing every one of those collaborators — most of which have +// nothing to do with the orphan path. To keep this test focused (and +// surgical to the F3 audit gap), the orphan listener-effect lives in a +// sibling component `OrphanListener.tsx`. `` mounts it +// unconditionally; mounting `` in isolation here +// exercises the exact same effect with none of the noise. + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// We need the captured listener callback so the test can fire a fake +// orphan-detected event, plus a spy on `cleanupOrphans` so we can +// assert the auto-cleanup invocation. Mock the whole interactive +// service module before importing the component-under-test. +let capturedHandler: ((ev: { sids: string[] }) => void) | null = null; +let unlistenSpy: ReturnType | null = null; +const cleanupOrphansSpy = vi.fn(); +const subscribeOrphansSpy = vi.fn(); + +vi.mock("./services/interactive", () => ({ + subscribeOrphansDetected: (fn: (ev: { sids: string[] }) => void) => + subscribeOrphansSpy(fn), + cleanupOrphans: (): Promise => cleanupOrphansSpy(), +})); + +import { OrphanListener } from "./OrphanListener"; +import { useAppStore } from "./stores/useAppStore"; + +const mountedRoots: Root[] = []; +const mountedContainers: HTMLElement[] = []; + +async function mountListener(): 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(); + }); + // The subscribe call is async — flush microtasks so the listener + // promise resolves and `capturedHandler` is populated before the + // test fires a fake event. + await act(async () => { + await Promise.resolve(); + }); + return root; +} + +beforeEach(() => { + capturedHandler = null; + unlistenSpy = null; + cleanupOrphansSpy.mockReset(); + cleanupOrphansSpy.mockResolvedValue([] as string[]); + subscribeOrphansSpy.mockReset(); + subscribeOrphansSpy.mockImplementation( + (fn: (ev: { sids: string[] }) => void) => { + capturedHandler = fn; + unlistenSpy = vi.fn(); + return Promise.resolve(unlistenSpy); + }, + ); + useAppStore.setState({ toasts: [] }); +}); + +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("OrphanListener (App-level orphan-detected wiring)", () => { + it("shows a toast when an orphan-detected event fires", async () => { + await mountListener(); + expect(capturedHandler).not.toBeNull(); + + await act(async () => { + capturedHandler?.({ sids: ["claudette-sess-a", "claudette-sess-b"] }); + }); + + const toasts = useAppStore.getState().toasts; + expect(toasts).toHaveLength(1); + expect(toasts[0]?.message).toMatch(/2 orphan interactive sessions/); + }); + + it("auto-invokes cleanupOrphans after the toast", async () => { + await mountListener(); + expect(capturedHandler).not.toBeNull(); + + expect(cleanupOrphansSpy).not.toHaveBeenCalled(); + await act(async () => { + capturedHandler?.({ sids: ["claudette-sess-a"] }); + }); + + // The cleanup invocation is synchronous from the handler's + // perspective (Promise spawned, no await in the listener), so it + // should already be queued by the time we assert. + expect(cleanupOrphansSpy).toHaveBeenCalledTimes(1); + // Flush the cleanup promise so the `.then` runs without a Vitest + // unhandled-rejection complaint. + await act(async () => { + await Promise.resolve(); + }); + + // Toast wording uses the singular form for sids.length === 1 — + // pin it so a future copy edit doesn't silently regress the + // pluralization branch. + const toasts = useAppStore.getState().toasts; + expect(toasts).toHaveLength(1); + expect(toasts[0]?.message).toMatch(/1 orphan interactive session\b/); + expect(toasts[0]?.message).not.toMatch(/sessions/); + }); + + it("calls the unlisten function on unmount", async () => { + const root = await mountListener(); + expect(unlistenSpy).not.toBeNull(); + expect(unlistenSpy).not.toHaveBeenCalled(); + + // Drop the mounted root from the cleanup list so afterEach doesn't + // try to unmount a second time. + const idx = mountedRoots.indexOf(root); + if (idx >= 0) mountedRoots.splice(idx, 1); + + await act(async () => { + root.unmount(); + }); + + expect(unlistenSpy).toHaveBeenCalledTimes(1); + }); + + it("ignores orphan-detected events with an empty sids list", async () => { + await mountListener(); + expect(capturedHandler).not.toBeNull(); + + await act(async () => { + capturedHandler?.({ sids: [] }); + }); + + expect(useAppStore.getState().toasts).toHaveLength(0); + expect(cleanupOrphansSpy).not.toHaveBeenCalled(); + }); + + it("catches subscribeOrphansDetected rejection without crashing", async () => { + // Mirror the `.catch` pattern in `InteractiveTerminalMode.tsx`: if + // the subscription promise rejects, the component must swallow the + // error (with a `console.warn`) instead of bubbling an unhandled + // rejection that would crash the React tree. + const rejection = new Error("subscribe failed"); + subscribeOrphansSpy.mockReset(); + subscribeOrphansSpy.mockImplementation(() => Promise.reject(rejection)); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Mount must not throw, despite the rejected subscribe promise. + await mountListener(); + + // The .catch handler is queued as a microtask — flush so the + // console.warn fires before we assert. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(warnSpy).toHaveBeenCalledWith( + "[OrphanListener] subscribeOrphansDetected failed:", + rejection, + ); + warnSpy.mockRestore(); + }); +}); diff --git a/src/ui/src/App.tsx b/src/ui/src/App.tsx index 480652eb0..0a7b34779 100644 --- a/src/ui/src/App.tsx +++ b/src/ui/src/App.tsx @@ -31,6 +31,7 @@ import { } from "./components/settings/codexBackendMigration"; import { autoDetectStartupAgentBackends } from "./components/settings/agentBackendStartupRefresh"; import { findLeafByPtyId } from "./stores/terminalPaneTree"; +import { OrphanListener } from "./OrphanListener"; import type { CommandEvent } from "./types"; import i18n, { isSupportedLanguage } from "./i18n"; import "./styles/theme.css"; @@ -916,6 +917,11 @@ function App() { store.updateWorkspace(workspaceId, { agent_status: "Running" }); }); + // Orphan-detection (`interactive://orphans-detected`) lives in + // `` so the listener-effect can be tested without + // pulling the rest of App's provider graph into the suite — see + // `App.orphans.test.tsx`. + const unlistenAutoArchived = listen<{ workspace_id: string; workspace_name: string; pr_number?: number; deleted?: boolean }>("workspace-auto-archived", (event) => { const { workspace_id, workspace_name, pr_number, deleted } = event.payload; const store = useAppStore.getState(); @@ -1081,7 +1087,12 @@ function App() { }, []); if (!viewStateHydrated) return null; - return ; + return ( + <> + + + + ); } export default App; diff --git a/src/ui/src/OrphanListener.tsx b/src/ui/src/OrphanListener.tsx new file mode 100644 index 000000000..828441260 --- /dev/null +++ b/src/ui/src/OrphanListener.tsx @@ -0,0 +1,78 @@ +import { useEffect } from "react"; + +import { useAppStore } from "./stores/useAppStore"; +import { + cleanupOrphans, + subscribeOrphansDetected, + type OrphansDetectedEvent, +} from "./services/interactive"; + +/** + * Mounts the one-shot boot-time `interactive://orphans-detected` + * listener emitted by `claudette::interactive_lifecycle`. The event + * fires when the host (tmux / sidecar) reports `claudette-` sessions + * Claudette's DB doesn't track — typically left over from a previous + * Claudette process that crashed. + * + * On receipt we: + * 1. Surface a one-shot toast so the user knows something + * recoverable happened. + * 2. Automatically invoke `cleanupOrphans` to stop the orphan + * sessions. The Rust side clears its orphan map either way, so a + * failed cleanup is logged but not re-tried — a second toast won't + * reappear on the next boot. + * + * Lives as a sibling of `App.tsx` (rather than inline in App's giant + * effect) so the lifecycle can be tested in isolation without bringing + * the rest of App's provider graph along — see `App.orphans.test.tsx`. + */ +export function OrphanListener(): null { + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + + const handle = (payload: OrphansDetectedEvent): void => { + const sids = payload?.sids ?? []; + if (sids.length === 0) return; + console.warn( + `[interactive] detected ${sids.length} orphan session(s) on the host:`, + sids, + "— invoke interactive_cleanup_orphans to stop them.", + ); + const store = useAppStore.getState(); + store.addToast( + `Cleaning up ${sids.length} orphan interactive session${sids.length === 1 ? "" : "s"} from a previous run.`, + ); + cleanupOrphans() + .then((stopped) => { + console.info( + `[interactive] cleaned up ${stopped.length} of ${sids.length} orphan session(s)`, + stopped, + ); + }) + .catch((err) => { + console.error("[interactive] cleanupOrphans failed", err); + }); + }; + + void subscribeOrphansDetected(handle) + .then((fn) => { + if (cancelled) { + // Listener resolved after unmount — drop it immediately. + fn(); + return; + } + unlisten = fn; + }) + .catch((err) => { + console.warn("[OrphanListener] subscribeOrphansDetected failed:", err); + }); + + return () => { + cancelled = true; + if (unlisten !== null) unlisten(); + }; + }, []); + + return null; +} 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 subscribeOutputMock = vi.fn( + async (_sid: string, _fn: (ev: unknown) => void) => { + return vi.fn(); + }, +); + +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), +})); + +// xterm.css side-effect import — happy-dom doesn't parse stylesheets. +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + +// Minimal Terminal fake. We only need disposal-order recording and a +// container to render into — keystroke forwarding is covered elsewhere. +interface TerminalSpy { + dataDisposeMock: Mock; + termDisposeMock: Mock; + disposeOrder: string[]; +} + +const terminalSpies: TerminalSpy[] = []; + +vi.mock("@xterm/xterm", () => { + class FakeTerminal { + private spy: TerminalSpy; + + constructor(_opts: unknown) { + const order: string[] = []; + const spy: TerminalSpy = { + dataDisposeMock: vi.fn(() => { + order.push("data"); + }), + termDisposeMock: vi.fn(() => { + order.push("term"); + }), + disposeOrder: order, + }; + this.spy = spy; + terminalSpies.push(spy); + } + + onData(_cb: (data: string) => void) { + return { dispose: this.spy.dataDisposeMock }; + } + + loadAddon(_addon: unknown) { + /* no-op */ + } + + open(_container: HTMLElement) { + /* no-op */ + } + + write(_data: unknown) { + /* no-op */ + } + + dispose() { + this.spy.termDisposeMock(); + } + } + return { Terminal: FakeTerminal }; +}); + +vi.mock("@xterm/addon-fit", () => { + class FakeFitAddon { + public fit = vi.fn(); + activate() { + /* no-op */ + } + dispose() { + /* no-op */ + } + } + return { FitAddon: FakeFitAddon }; +}); + +// ResizeObserver — disconnect recording lets us pin the post-unmount +// cleanup order even when other paths in the effect throw. +class FakeResizeObserver { + public disconnectMock = vi.fn(); + constructor(_cb: ResizeObserverCallback) { + /* no-op */ + } + observe(_target: Element) { + /* no-op */ + } + unobserve() { + /* no-op */ + } + disconnect() { + this.disconnectMock(); + } +} + +// ---------- Harness ------------------------------------------------------ + +// Import after mocks are registered. +import { InteractiveTerminalMode } from "./InteractiveTerminalMode"; + +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); + }); + // Flush microtasks so the `.then(...)` / `.catch(...)` chains run. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + return container; +} + +async function unmountAll() { + for (const root of mountedRoots.splice(0).reverse()) { + await act(async () => { + root.unmount(); + }); + } + for (const container of mountedContainers.splice(0)) { + container.remove(); + } +} + +let warnSpy: ReturnType; + +beforeEach(() => { + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + // The component logs rejection paths via console.warn — silence the + // noise while still letting tests assert on call counts. + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); +}); + +afterEach(async () => { + await unmountAll(); + attachMock.mockReset(); + attachMock.mockImplementation(async () => undefined); + sendInputMock.mockReset(); + sendInputMock.mockImplementation(async () => undefined); + subscribeOutputMock.mockReset(); + subscribeOutputMock.mockImplementation(async () => vi.fn()); + terminalSpies.length = 0; + warnSpy.mockRestore(); + vi.unstubAllGlobals(); +}); + +// ---------- Tests -------------------------------------------------------- + +describe("InteractiveTerminalMode failure paths", () => { + it("logs and keeps rendering when `attach` rejects on mount", async () => { + attachMock.mockRejectedValueOnce(new Error("attach boom")); + + const container = await render(); + + // The xterm container element is created synchronously in the + // effect (we mocked `open` to a no-op so there's no `.xterm` + // sentinel — instead we look for the wrapper div the component + // owns). + expect( + container.querySelector('[data-testid="interactive-terminal-mode"]'), + ).not.toBeNull(); + + // The catch handler in the effect should have routed the failure + // through console.warn — that's what proves we caught the + // rejection instead of letting it surface as unhandledrejection. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("attach failed"), + expect.any(Error), + ); + // The terminal mount should NOT have been aborted by the failure. + expect(terminalSpies).toHaveLength(1); + }); + + it("logs and keeps rendering when `subscribeOutput` rejects", async () => { + subscribeOutputMock.mockRejectedValueOnce(new Error("subscribe boom")); + + const container = await render(); + + expect( + container.querySelector('[data-testid="interactive-terminal-mode"]'), + ).not.toBeNull(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("subscribeOutput failed"), + expect.any(Error), + ); + // The mount completed — terminal and (regardless of subscription + // failure) the rest of the effect ran. + expect(terminalSpies).toHaveLength(1); + }); + + it("completes unmount cleanly when the unlisten function throws", async () => { + const throwingUnlisten = vi.fn(() => { + throw new Error("unlisten boom"); + }); + subscribeOutputMock.mockImplementationOnce(async () => throwingUnlisten); + + await render(); + + expect(terminalSpies).toHaveLength(1); + const spy = terminalSpies[0]!; + + // Unmount must not propagate the unlisten throw — otherwise React + // logs an "uncaught error in cleanup" and downstream cleanup + // (terminal dispose) silently leaks. + await expect(unmountAll()).resolves.toBeUndefined(); + + // The unlisten was attempted and the terminal was still disposed. + expect(throwingUnlisten).toHaveBeenCalledTimes(1); + expect(spy.termDisposeMock).toHaveBeenCalledTimes(1); + expect(spy.dataDisposeMock).toHaveBeenCalledTimes(1); + }); + + it("calls the resolved unlisten immediately when `subscribeOutput` settles after unmount", async () => { + // Hold the subscribeOutput promise open across the unmount so we + // can race the resolution against effect teardown. + const lateUnlisten = vi.fn(); + let resolveUnlisten: ((u: typeof lateUnlisten) => void) | null = null; + const pending = new Promise((resolve) => { + resolveUnlisten = resolve; + }); + subscribeOutputMock.mockImplementationOnce(() => pending); + + // Mount, then unmount BEFORE the subscribeOutput promise resolves. + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + // Unmount before resolving — sets the effect's `cancelled` flag. + await act(async () => { + root.unmount(); + }); + container.remove(); + + // The component should not have attached the unlisten yet. + expect(lateUnlisten).not.toHaveBeenCalled(); + + // Now resolve the held subscribeOutput promise. The `.then` chain + // in the component should fire its `cancelled` branch and invoke + // the unlisten immediately so the listener does not leak. + await act(async () => { + resolveUnlisten!(lateUnlisten); + // Two microtask drains: one for the `.then`, one for any + // chained handler in the component. + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lateUnlisten).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui/src/components/chat/InteractiveTerminalMode.keystrokes.test.tsx b/src/ui/src/components/chat/InteractiveTerminalMode.keystrokes.test.tsx new file mode 100644 index 000000000..9dbbf568f --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTerminalMode.keystrokes.test.tsx @@ -0,0 +1,284 @@ +// @vitest-environment happy-dom + +// Focused tests for the keystroke + layout plumbing inside +// `InteractiveTerminalMode`. These complement the existing smoke test, +// which runs against the real xterm.js. Here we swap `@xterm/xterm` and +// `@xterm/addon-fit` for fakes so we can: +// - capture the `onData` handler registered on the terminal and assert +// that invoking it routes through the G3 `sendInput` service; +// - capture the `ResizeObserver` callback and verify that container +// reshapes re-run `fit.fit()`; +// - observe disposal order on unmount — the audit calls out that the +// `onData` disposable must be disposed BEFORE the terminal itself. +// +// Mocking the Terminal class is necessary because xterm's real +// `Terminal.dispose()` does not expose call-order to assertions and +// `onData` is registered against the renderer, not the public surface. +// A fake gives us precise visibility into both. + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; + +// ---------- Mocks -------------------------------------------------------- + +const attachMock = vi.fn(async (_sid: string) => undefined); +const sendInputMock = vi.fn(async (_sid: string, _text: string) => undefined); +const subscribeOutputMock = vi.fn( + async (_sid: string, _fn: (ev: unknown) => void) => { + return vi.fn(); + }, +); + +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), +})); + +// xterm.css side-effect import in the component would try to read a +// stylesheet; stub it out so happy-dom doesn't choke. +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + +// Terminal fake. We record the most-recently-constructed instance on a +// module-level slot so the test can grab the data handler / disposers. +interface TerminalSpy { + onDataCallback: ((data: string) => void) | null; + dataDisposeMock: Mock; + termDisposeMock: Mock; + disposeOrder: string[]; + loadAddonMock: Mock; + openMock: Mock; + writeMock: Mock; +} + +const terminalSpies: TerminalSpy[] = []; + +vi.mock("@xterm/xterm", () => { + class FakeTerminal { + private spy: TerminalSpy; + + constructor(_opts: unknown) { + const order: string[] = []; + const spy: TerminalSpy = { + onDataCallback: null, + dataDisposeMock: vi.fn(() => { + order.push("data"); + }), + termDisposeMock: vi.fn(() => { + order.push("term"); + }), + disposeOrder: order, + loadAddonMock: vi.fn(), + openMock: vi.fn(), + writeMock: vi.fn(), + }; + this.spy = spy; + terminalSpies.push(spy); + } + + onData(cb: (data: string) => void) { + this.spy.onDataCallback = cb; + return { dispose: this.spy.dataDisposeMock }; + } + + loadAddon(addon: unknown) { + this.spy.loadAddonMock(addon); + } + + open(container: HTMLElement) { + this.spy.openMock(container); + } + + write(data: unknown) { + this.spy.writeMock(data); + } + + dispose() { + this.spy.termDisposeMock(); + } + } + return { Terminal: FakeTerminal }; +}); + +// FitAddon fake — we only need a `.fit()` spy and a no-op `activate` +// signature so `term.loadAddon(fit)` is harmless. +const fitMocks: Mock[] = []; + +vi.mock("@xterm/addon-fit", () => { + class FakeFitAddon { + public fit: Mock; + constructor() { + this.fit = vi.fn(); + fitMocks.push(this.fit); + } + activate() { + // no-op — Terminal.loadAddon would normally invoke this. + } + dispose() { + // no-op + } + } + return { FitAddon: FakeFitAddon }; +}); + +// ResizeObserver fake. We capture the callback so the test can trigger +// it manually; `observe` / `disconnect` are also recorded for the +// disposal-order test. +type ROCallback = ResizeObserverCallback; +const resizeObservers: Array<{ + cb: ROCallback; + observeMock: Mock; + disconnectMock: Mock; +}> = []; + +class FakeResizeObserver { + public observeMock: Mock; + public disconnectMock: Mock; + constructor(cb: ROCallback) { + this.observeMock = vi.fn(); + this.disconnectMock = vi.fn(); + resizeObservers.push({ + cb, + observeMock: this.observeMock, + disconnectMock: this.disconnectMock, + }); + } + observe(target: Element) { + this.observeMock(target); + } + unobserve() { + /* no-op */ + } + disconnect() { + this.disconnectMock(); + } +} + +// ---------- Harness ------------------------------------------------------ + +// Import after mocks are registered so the component picks them up. +import { InteractiveTerminalMode } from "./InteractiveTerminalMode"; + +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); + }); + // Flush the microtask queue so `subscribeOutput().then(...)` runs. + await act(async () => { + await Promise.resolve(); + }); + return container; +} + +async function unmountAll() { + for (const root of mountedRoots.splice(0).reverse()) { + await act(async () => { + root.unmount(); + }); + } + for (const container of mountedContainers.splice(0)) { + container.remove(); + } +} + +beforeEach(() => { + vi.stubGlobal("ResizeObserver", FakeResizeObserver); +}); + +afterEach(async () => { + await unmountAll(); + attachMock.mockClear(); + sendInputMock.mockClear(); + subscribeOutputMock.mockClear(); + terminalSpies.length = 0; + fitMocks.length = 0; + resizeObservers.length = 0; + vi.unstubAllGlobals(); +}); + +// ---------- Tests -------------------------------------------------------- + +describe("InteractiveTerminalMode keystroke + layout wiring", () => { + it("forwards xterm `onData` to the G3 sendInput service", async () => { + await render(); + + expect(terminalSpies).toHaveLength(1); + const spy = terminalSpies[0]!; + expect(spy.onDataCallback).not.toBeNull(); + + // Simulate a single keystroke flowing out of xterm. + spy.onDataCallback!("x"); + + // sendInput is fire-and-forget but invoked synchronously from the + // onData callback, so it should be recorded before any awaits. + expect(sendInputMock).toHaveBeenCalledWith("sid-key", "x"); + expect(sendInputMock).toHaveBeenCalledTimes(1); + }); + + it("re-runs fit.fit() when ResizeObserver fires", async () => { + await render(); + + expect(fitMocks).toHaveLength(1); + expect(resizeObservers).toHaveLength(1); + const fit = fitMocks[0]!; + const ro = resizeObservers[0]!; + + // Component runs an initial fit() before installing the observer, + // so account for that baseline. + const baseline = fit.mock.calls.length; + + // Manually fire the ResizeObserver callback the way the browser + // would on a container reshape. The component ignores the entry + // payload, so an empty array is sufficient. + act(() => { + ro.cb([] as unknown as ResizeObserverEntry[], {} as ResizeObserver); + }); + + expect(fit.mock.calls.length).toBe(baseline + 1); + }); + + it("disposes the onData disposable before the terminal on unmount", async () => { + await render(); + + expect(terminalSpies).toHaveLength(1); + const spy = terminalSpies[0]!; + expect(resizeObservers).toHaveLength(1); + const ro = resizeObservers[0]!; + + await unmountAll(); + + // Both disposers fired exactly once. + expect(spy.dataDisposeMock).toHaveBeenCalledTimes(1); + expect(spy.termDisposeMock).toHaveBeenCalledTimes(1); + + // Order: ResizeObserver → onData disposable → terminal. The audit + // calls out the data-before-terminal sequence explicitly; we also + // pin disconnect-before-dispose so a future refactor that reorders + // teardown trips this guard. + expect(spy.disposeOrder).toEqual(["data", "term"]); + const dataCallOrder = spy.dataDisposeMock.mock.invocationCallOrder[0]!; + const termCallOrder = spy.termDisposeMock.mock.invocationCallOrder[0]!; + const disconnectCallOrder = + ro.disconnectMock.mock.invocationCallOrder[0]!; + expect(disconnectCallOrder).toBeLessThan(dataCallOrder); + expect(dataCallOrder).toBeLessThan(termCallOrder); + }); +}); diff --git a/src/ui/src/components/chat/InteractiveTerminalMode.module.css b/src/ui/src/components/chat/InteractiveTerminalMode.module.css new file mode 100644 index 000000000..c547319a1 --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTerminalMode.module.css @@ -0,0 +1,22 @@ +/* Full-terminal mode for an interactive Claude session. The host + * container claims the entire chat scroll area; FitAddon takes care of + * dimension math from there, mirroring how TerminalPanel sizes its + * own leaves. */ + +.interactiveTerminalMode { + width: 100%; + height: 100%; + min-height: 240px; + background: var(--terminal-bg); + color: var(--terminal-fg); + border: 1px solid var(--divider); + border-radius: 4px; + padding: 4px; + overflow: hidden; + box-sizing: border-box; +} + +.interactiveTerminalMode :global(.xterm) { + width: 100%; + height: 100%; +} diff --git a/src/ui/src/components/chat/InteractiveTerminalMode.test.tsx b/src/ui/src/components/chat/InteractiveTerminalMode.test.tsx new file mode 100644 index 000000000..868888d22 --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTerminalMode.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom + +// Smoke test for the full-terminal interactive view. We mock the G3 +// service surface so the component can mount / unmount without any +// actual Tauri runtime, and confirm: +// - The xterm host element appears (xterm.js's `.xterm` sentinel). +// - `attach` is invoked for the supplied sid. +// - `subscribeOutput` is invoked for the supplied sid and the +// resolved unlisten function is called on unmount (no leak). + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { InteractiveTerminalMode } from "./InteractiveTerminalMode"; + +const attachMock = vi.fn(async (_sid: string) => 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 — vitest treats no-throw as pass. + }); +}); diff --git a/src/ui/src/components/chat/InteractiveTerminalMode.tsx b/src/ui/src/components/chat/InteractiveTerminalMode.tsx new file mode 100644 index 000000000..0e95f5c58 --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTerminalMode.tsx @@ -0,0 +1,162 @@ +// 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; + }) + .catch((err) => { + // Symmetric with the `attach` catch below: log and continue so + // the terminal still mounts. Without this, a rejected + // subscribe surfaces as an unhandled promise rejection. + console.warn("[interactive] subscribeOutput failed:", err); + }); + + // 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) { + try { + unlistenOutput(); + } catch (err) { + // Guard against a throwing unlisten so the terminal still + // gets disposed — otherwise we'd leak the xterm instance + // and its DOM nodes if the listener teardown failed. + console.warn("[interactive] unlistenOutput threw:", err); + } + } + 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.test.tsx b/src/ui/src/components/chat/InteractiveTerminalModeToggle.test.tsx new file mode 100644 index 000000000..79d84e78b --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTerminalModeToggle.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment happy-dom +// +// Coverage for the "Open in terminal" / "Back to chat" header toggle. +// The component is small but it owns three orthogonal gates — interactive +// harness, workspace id, session id — and a label/icon flip driven by +// the per-workspace `interactiveTerminalModeByWorkspace` map. Each branch +// needs an assertion to satisfy the patch-coverage gate. + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { InteractiveTerminalModeToggle } from "./InteractiveTerminalModeToggle"; +import { useAppStore } from "../../stores/useAppStore"; +import type { AgentBackendConfig } from "../../services/tauri/agentBackends"; + +function makeInteractiveBackend( + 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: "claude_interactive", + ...overrides, + }; +} + +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(() => { + useAppStore.setState({ + agentBackends: [], + defaultAgentBackendId: "anthropic", + selectedModelProvider: {}, + selectedWorkspaceId: null, + selectedSessionIdByWorkspaceId: {}, + 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("InteractiveTerminalModeToggle", () => { + it("renders nothing when the harness is not interactive", async () => { + useAppStore.setState({ + agentBackends: [ + makeInteractiveBackend({ runtime_harness: undefined }), + ], + selectedModelProvider: { "sess-1": "anthropic" }, + selectedWorkspaceId: "ws-1", + selectedSessionIdByWorkspaceId: { "ws-1": "sess-1" }, + claudeInteractiveEnabled: true, + }); + + const container = await render(); + expect( + container.querySelector("[data-testid='interactive-terminal-mode-toggle']"), + ).toBeNull(); + }); + + it("renders nothing when there is no selected workspace", async () => { + useAppStore.setState({ + agentBackends: [makeInteractiveBackend()], + selectedModelProvider: { "sess-1": "anthropic" }, + selectedWorkspaceId: null, + selectedSessionIdByWorkspaceId: {}, + claudeInteractiveEnabled: true, + }); + + const container = await render(); + expect( + container.querySelector("[data-testid='interactive-terminal-mode-toggle']"), + ).toBeNull(); + }); + + it("renders nothing when there is no active session for the workspace", async () => { + useAppStore.setState({ + agentBackends: [makeInteractiveBackend()], + selectedModelProvider: { "sess-1": "anthropic" }, + selectedWorkspaceId: "ws-1", + // No entry in selectedSessionIdByWorkspaceId → null active session. + selectedSessionIdByWorkspaceId: {}, + claudeInteractiveEnabled: true, + }); + + const container = await render(); + expect( + container.querySelector("[data-testid='interactive-terminal-mode-toggle']"), + ).toBeNull(); + }); + + it("renders the 'Open in terminal' affordance when interactive and chat mode", async () => { + useAppStore.setState({ + agentBackends: [makeInteractiveBackend()], + selectedModelProvider: { "sess-1": "anthropic" }, + selectedWorkspaceId: "ws-1", + selectedSessionIdByWorkspaceId: { "ws-1": "sess-1" }, + claudeInteractiveEnabled: true, + interactiveTerminalModeByWorkspace: {}, + }); + + const container = await render(); + const btn = container.querySelector( + "[data-testid='interactive-terminal-mode-toggle']", + ); + expect(btn).not.toBeNull(); + expect(btn?.getAttribute("aria-pressed")).toBe("false"); + expect(btn?.getAttribute("aria-label")).toBe("Open in terminal"); + }); + + it("renders the 'Back to chat' affordance when the terminal mode is active", async () => { + useAppStore.setState({ + agentBackends: [makeInteractiveBackend()], + selectedModelProvider: { "sess-1": "anthropic" }, + selectedWorkspaceId: "ws-1", + selectedSessionIdByWorkspaceId: { "ws-1": "sess-1" }, + claudeInteractiveEnabled: true, + interactiveTerminalModeByWorkspace: { "ws-1": true }, + }); + + const container = await render(); + const btn = container.querySelector( + "[data-testid='interactive-terminal-mode-toggle']", + ); + expect(btn).not.toBeNull(); + expect(btn?.getAttribute("aria-pressed")).toBe("true"); + expect(btn?.getAttribute("aria-label")).toBe("Back to chat"); + }); + + it("flips the per-workspace terminalMode flag when clicked", async () => { + useAppStore.setState({ + agentBackends: [makeInteractiveBackend()], + selectedModelProvider: { "sess-1": "anthropic" }, + selectedWorkspaceId: "ws-1", + selectedSessionIdByWorkspaceId: { "ws-1": "sess-1" }, + claudeInteractiveEnabled: true, + }); + + const container = await render(); + const btn = container.querySelector( + "[data-testid='interactive-terminal-mode-toggle']", + ); + expect(btn).not.toBeNull(); + + await act(async () => { + btn!.click(); + }); + expect( + useAppStore.getState().interactiveTerminalModeByWorkspace["ws-1"], + ).toBe(true); + + await act(async () => { + btn!.click(); + }); + // Toggling off removes the key from the map (see uiSlice convention). + expect( + useAppStore.getState().interactiveTerminalModeByWorkspace["ws-1"], + ).toBeUndefined(); + }); +}); 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/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..a225fd0fe --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTurnView.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Terminal } from "@xterm/xterm"; + +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", 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( + , + ); + }); + 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.render( + , + ); + }); + + 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 () => { + 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..791984db4 --- /dev/null +++ b/src/ui/src/components/chat/InteractiveTurnView.tsx @@ -0,0 +1,140 @@ +// 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); + // 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; + + 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. + } + + // 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. 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; + 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
; +} 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/settings/RuntimeSelector.test.tsx b/src/ui/src/components/settings/RuntimeSelector.test.tsx index 0d08df518..a34bec688 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", () => { @@ -41,26 +45,63 @@ const serviceMocks = vi.hoisted(() => ({ return "pi_sdk" as const; } }, - availableHarnessesForKind: (kind: AgentBackendConfig["kind"]) => { - switch (kind) { - case "anthropic": - case "custom_anthropic": - case "codex_subscription": - return ["claude_code"] as const; - case "ollama": - case "lm_studio": - return ["pi_sdk", "claude_code"] as const; - case "openai_api": - case "custom_openai": - return ["claude_code", "pi_sdk"] as const; - case "codex_native": - return ["codex_app_server", "pi_sdk"] as const; - case "pi_sdk": - return ["pi_sdk"] as const; + availableHarnessesForKind: ( + kind: AgentBackendConfig["kind"], + options?: { claudeInteractiveEnabled?: boolean }, + ) => { + const base: string[] = (() => { + 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; }, - 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); }, })); @@ -231,4 +272,131 @@ describe("RuntimeSelector", () => { "claude_code", ); }); + + it("hides the Claude (Interactive) option for an Anthropic backend when the flag is OFF", () => { + // Default appStore.claudeInteractiveEnabled is false → the + // Anthropic kind only has one available harness, so the selector + // shouldn't render at all. + appStore.claudeInteractiveEnabled = false; + const container = mount( + {}} + />, + ); + expect(container.querySelector("select")).toBeNull(); + }); + + it("renders the Claude (Interactive) option for an Anthropic backend when the flag is ON", () => { + appStore.claudeInteractiveEnabled = true; + try { + const container = mount( + {}} + />, + ); + const select = container.querySelector("select") as HTMLSelectElement; + expect(select).not.toBeNull(); + const values = Array.from(select.options).map((o) => o.value); + expect(values).toEqual(["claude_code", "claude_interactive"]); + // ClaudeCode is the kind's default → first entry is marked default. + expect(select.options[0]!.textContent).toMatch(/default/i); + // Currently selected is the kind default (no override persisted). + expect(select.value).toBe("claude_code"); + } finally { + appStore.claudeInteractiveEnabled = false; + } + }); + + it("persists the claude_interactive harness when the user selects it (flag on)", async () => { + appStore.claudeInteractiveEnabled = true; + try { + const container = mount( + {}} + />, + ); + const select = container.querySelector("select") as HTMLSelectElement; + await act(async () => { + select.value = "claude_interactive"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(serviceMocks.setAgentBackendRuntimeHarness).toHaveBeenCalledTimes(1); + // claude_interactive is NOT the kind default, so the override is + // passed through verbatim (not nulled out). + expect(serviceMocks.setAgentBackendRuntimeHarness).toHaveBeenCalledWith( + "anthropic", + "claude_interactive", + ); + } finally { + appStore.claudeInteractiveEnabled = false; + } + }); + + it("does not expose Claude (Interactive) for non-Claude-flavored kinds even when the flag is on", () => { + // Ollama / LM Studio / OpenAI / CodexNative / PiSdk must never + // surface claude_interactive — the harness is a Claude-runtime + // variant only. + appStore.claudeInteractiveEnabled = true; + appStore.agentBackends = [ + makeBackend({ id: "pi", kind: "pi_sdk", enabled: true }), + ]; + try { + for (const kind of [ + "ollama", + "lm_studio", + "openai_api", + "custom_openai", + "codex_native", + ] as const) { + const container = mount( + {}} + />, + ); + const select = container.querySelector("select"); + if (select) { + const values = Array.from(select.options).map((o) => o.value); + expect(values).not.toContain("claude_interactive"); + } + container.remove(); + } + } finally { + 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"); + }); }); diff --git a/src/ui/src/components/settings/RuntimeSelector.tsx b/src/ui/src/components/settings/RuntimeSelector.tsx index 46b3c7cf4..dda210d77 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": @@ -51,16 +55,19 @@ function harnessFallbackLabel(harness: AgentBackendRuntimeHarness): string { export function RuntimeSelector({ backend, onSaved, onError }: RuntimeSelectorProps) { const { t } = useTranslation("settings"); const piSdkAvailable = useAppStore((s) => s.piSdkAvailable); + const claudeInteractiveEnabled = useAppStore((s) => s.claudeInteractiveEnabled); const harnesses = useMemo(() => { - const all = availableHarnessesForKind(backend.kind); + const all = availableHarnessesForKind(backend.kind, { + claudeInteractiveEnabled, + }); // Hide the Pi runtime option entirely on builds that didn't // compile the Pi harness in — the dispatcher would fall back to // ClaudeCode anyway, but exposing the option here would let the // user pin a setting that has no effect. return piSdkAvailable ? all : all.filter((h) => h !== "pi_sdk"); - }, [backend.kind, piSdkAvailable]); + }, [backend.kind, piSdkAvailable, claudeInteractiveEnabled]); const defaultHarness = defaultHarnessForKind(backend.kind); - const current = effectiveHarness(backend); + 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/ExperimentalSettings.test.tsx b/src/ui/src/components/settings/sections/ExperimentalSettings.test.tsx index 1da005f50..8983b7dc2 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(() => ({ @@ -152,10 +156,38 @@ describe("ExperimentalSettings — Usage Insights consent gate", () => { it("does not render the removed OpenRouter balance toggle", async () => { const container = await renderSettings(); const switches = Array.from(container.querySelectorAll('[role="switch"]')); + const labels = switches.map((s) => s.getAttribute("aria-label")); + + expect(labels).not.toContain("experimental_openrouter_balance_aria"); + expect(labels).toContain("experimental_claude_code_usage_aria"); + }); +}); + +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(switches).toHaveLength(1); - expect(switches[0]?.getAttribute("aria-label")).toBe( - "experimental_claude_code_usage_aria", + 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")} +
+
+
+ +
+
+
({ 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..eae9ef9f3 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,33 @@ export function ModelSettings() {
+ {/* 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. */} +
+
+
+ {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) && ( + 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..4af93771b --- /dev/null +++ b/src/ui/src/components/sidebar/InteractiveBadge.test.tsx @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { renderToString } from "react-dom/server"; +import { + InteractiveBadge, + computeInteractiveBadgeState, +} from "./InteractiveBadge"; +import type { + InteractiveSessionRow, + InteractiveSessionState, +} 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 stopped / unknown sessions are persisted", () => { + const rows = [ + makeRow({ sid: "a", state: "stopped" }), + 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: "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"); + }); + + // F6 Step 1 — Mixed-state precedence. Workspace with a crashed row *and* an + // additional "awaiting" signal (delivered via the assembler flag, since the + // DB `state` column does not encode awaiting) plus an additional detached + // row, must still resolve to "crashed" — confirming the documented + // precedence: crashed > awaiting > detached > null. + it("resolves to 'crashed' when a workspace mixes crashed + awaiting + detached signals", () => { + const rows = [ + makeRow({ sid: "a", state: "detached" }), + makeRow({ sid: "b", state: "crashed" }), + makeRow({ sid: "c", state: "running" }), + ]; + expect(computeInteractiveBadgeState(rows, true)).toBe("crashed"); + }); + + // F6 Step 2 — Exhaustiveness. `badgeStateForRow` is not exported, but the + // contract it pins is observable through `computeInteractiveBadgeState` + // when called with a single row of each `InteractiveSessionState` value. + // Strict-mode `switch` exhaustiveness in the source guards this at compile + // time; this test pins the runtime mapping so the contract can't drift + // silently if a future state is added without a corresponding test update. + it("maps every InteractiveSessionState value through to the expected badge state", () => { + const expected: Record< + InteractiveSessionState, + "awaiting" | "detached" | "crashed" | null + > = { + running: "detached", + detached: "detached", + crashed: "crashed", + stopped: null, + unknown: null, + }; + const allStates: InteractiveSessionState[] = [ + "running", + "detached", + "crashed", + "stopped", + "unknown", + ]; + // Catch any drift between the literal union and the keys above — if a + // new state lands in the union without an entry in `expected`, this + // count check fails before the per-state assertions even run. + expect(allStates.length).toBe(Object.keys(expected).length); + for (const state of allStates) { + const rows = [makeRow({ state })]; + expect( + computeInteractiveBadgeState(rows), + `state="${state}" should map to ${expected[state]}`, + ).toBe(expected[state]); + } + }); +}); diff --git a/src/ui/src/components/sidebar/InteractiveBadge.tsx b/src/ui/src/components/sidebar/InteractiveBadge.tsx new file mode 100644 index 000000000..057a7a1a3 --- /dev/null +++ b/src/ui/src/components/sidebar/InteractiveBadge.tsx @@ -0,0 +1,184 @@ +// 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, + InteractiveSessionState, +} 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 +// --------------------------------------------------------------------------- + +/** + * 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 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 whose row contributes `"detached"`. + * 4. `null` — no badge. + * + * Sessions in DB states `"stopped"` / `"unknown"` contribute nothing — + * see {@link badgeStateForRow}. + */ +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 hasDetached = false; + for (const row of sessions) { + const contribution = badgeStateForRow(row.state); + if (contribution === "crashed") return "crashed"; + if (contribution === "detached") hasDetached = true; + } + if (awaitingFromAssembler) return "awaiting"; + if (hasDetached) 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..904544a98 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"; @@ -51,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 { @@ -66,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, @@ -116,6 +146,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); @@ -438,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: () => { @@ -482,6 +521,18 @@ 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); + await clipboardWriteText(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: @@ -498,6 +549,7 @@ export const Sidebar = memo(function Sidebar() { addToast, handleArchive, handleRestore, + interactiveSessionsByWorkspace, markWorkspaceAsUnread, openModal, setSessionsForWorkspace, @@ -754,6 +806,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/components/sidebar/workspaceContextMenu.test.tsx b/src/ui/src/components/sidebar/workspaceContextMenu.test.tsx index a2daefef1..b98bc58c3 100644 --- a/src/ui/src/components/sidebar/workspaceContextMenu.test.tsx +++ b/src/ui/src/components/sidebar/workspaceContextMenu.test.tsx @@ -1,10 +1,16 @@ import { describe, expect, it, vi } from "vitest"; +import { writeText as clipboardWriteText } from "@tauri-apps/plugin-clipboard-manager"; import { + buildTmuxAttachCommand, buildWorkspaceContextMenuItems, type WorkspaceContextMenuLabels, } from "./workspaceContextMenu"; import type { ContextMenuItem } from "../shared/ContextMenu"; +vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ + writeText: vi.fn().mockResolvedValue(undefined), +})); + const labels: WorkspaceContextMenuLabels = { renameWorkspace: "Rename Workspace…", markAsUnread: "Mark as Unread", @@ -12,6 +18,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 +124,109 @@ 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 () => { + const writeTextMock = vi.mocked(clipboardWriteText); + writeTextMock.mockClear(); + + 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 the Tauri + // clipboard plugin with the tmux attach command. + copyTmuxAttachCommand: async () => { + await clipboardWriteText(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(writeTextMock).toHaveBeenCalledTimes(1); + expect(writeTextMock).toHaveBeenCalledWith(`tmux attach-session -t ${sid}`); + }); }); 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: