feat(agent): experimental Claude (Interactive) backend (tmux + sidecar host)#855
feat(agent): experimental Claude (Interactive) backend (tmux + sidecar host)#855codefriar wants to merge 95 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #855 +/- ##
==========================================
- Coverage 80.73% 80.30% -0.44%
==========================================
Files 123 134 +11
Lines 46142 48624 +2482
==========================================
+ Hits 37255 39046 +1791
- Misses 8887 9578 +691 🚀 New features to boost your workflow:
|
452ef22 to
d93d520
Compare
|
Thanks @codefriar ! I will rebase this branch and check it out |
Design for a new experimental agent backend that runs interactive `claude` (no -p) inside a detachable host — tmux on Unix, a custom Rust `claudette-session-host` sidecar on Windows. Coexists with the existing print-mode path behind a `claudeInteractiveEnabled` Settings flag. Renderer perf is explicitly deferred to a follow-up spec. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… plan Bite-sized TDD plan covering Phases A-I: settings flag + migration, InteractiveHost trait + conformance suite, claudette-session-host sidecar crate, TmuxHost impl, hooks + CLI subcommand + IPC ingestion, backend wiring + Tauri commands, UI surfaces, lifecycle (reattach + cleanup + orphans), and docs/CLAUDE.md sync. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pin the round-trip contract for the new experimental flag through the generic `app_settings` table. No schema or IPC changes were required: `pluginManagementEnabled` (the model this flag follows) is also stored purely as a key-value string and exposed through the existing generic `get_app_setting` / `set_app_setting` Tauri commands. There is no typed Rust `AppSettings` struct, so Steps 4 and 5 of the implementation plan are intentional no-ops. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the post-handshake request loop in `server.rs` and adds a per-session PTY actor in `session.rs`. The actor owns the `PtyPair`, runs a blocking reader task that fans output into a 256 KB rolling capture buffer plus a `broadcast` channel for live attaches (C4), and a waiter task that emits an `Exit` event when the child terminates. A new `SessionMap = Arc<Mutex<HashMap<String, Arc<Session>>>>` is shared across connections so all clients see the same set of live sessions. `run_at_with` lets an outer harness pass its own map; `run_at` and `run_for_test` wrap a fresh one. Dispatch handles `EnsureSession`, `Status`, `SendInput`, and `Stop`; `Resize`, `Detach`, `CaptureScreen`, and `Attach` remain `not yet implemented` (they land in C4). Integration test `ensure_session.rs` builds the workspace `stub-tui` fixture via `cargo build -p stub-tui` and discovers its path through `cargo metadata`'s `target_directory` (artifact deps still require `-Z bindeps` on 1.94, so we sidestep them). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…top exit_status semantics
…tureScreen
Wire up the four request kinds the dispatch loop had stubbed out in C3:
- Attach: special-cased in handle_connection. After writing
Response::AttachStarted { attach_id }, the connection switches into
streaming mode and pumps SessionEvent::{Output,Hook,Exit} as wire
Event frames until the client disconnects or the session exits. The
per-connection attach_id_counter is a monotonic u64 so future control
flows can correlate Detach requests if needed.
- Detach: per the plan's simpler v1 model, the canonical way to detach
is to close the socket — stream_attach exits on the resulting write
error. The explicit Detach request is accepted for symmetry and
returns Response::Ok (effectively a no-op).
- Resize: clones the Arc<Session> out from under the map lock, then
awaits Session::resize.
- CaptureScreen: clones the Arc<Session>, reads the rolling raw-ANSI
buffer, and returns it base64-encoded alongside current rows/cols.
New integration test tests/attach_stream.rs uses two connections — a
control connection for EnsureSession+SendInput and an attach connection
that asserts both the AttachStarted ack and the streamed READY +
"OUT: hello" echo from stub-tui.
…eachable The reader's `Ok(0)` arm is collapsed with `Err(_)` because portable-pty's master reader returns zero only on real slave EOF — unlike TmuxHost's pipe-pane FIFO, which can transiently close on `respawn-pane`. The waiter's `Err(_)` arm covers a `Child::wait` failure mode that requires a lost child handle or external reaper; neither is reproducible from a unit test. Adds two `#[ignore]`d documentation tests (`reader_ok_zero_is_not_spurious_on_portable_pty`, `wait_err_path_is_unreachable`) and expands the inline comments in `Session::spawn` so grep finds the rationale next to the code. Closes Task C1 in `superpowers/plans/2026-05-18-interactive-claude-coverage-plan.md` as documentation-as-coverage; neither branch is reachable from a stable test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extract `reattach_interactive_sessions_inner(&AppState, OrphanEmitter)`
from `reattach_interactive_sessions_on_boot(AppHandle)` so the boot
reconciler can be unit-tested without booting a real Tauri runtime.
The public entry stays as a thin wrapper that resolves `AppState` from
the handle and wires `app.emit("interactive://orphans-detected", ...)`
into the inner reconciler's emit callback.
Add seven unit tests exercising the reconciler's behavior branches:
1. claudeInteractiveEnabled OFF → early return, no host touched.
2. Empty DB + empty known sids → fast-path return.
3. Single workspace, host knows session → row → `detached`.
4. Single workspace, host missing session → row → `crashed`.
5. Orphan fallback (no running rows, host has unknown claudette- sid)
records orphan into `AppState::interactive_orphans` and emits.
6. Per-workspace host failure isolation: one workspace's `status()`
error must not prevent the other workspace's rows from being
reclassified.
7. Orphan-emit failure is swallowed and orphans are still stashed
into `AppState::interactive_orphans` before the emit (so a
subsequent `interactive_cleanup_orphans` can still find them).
The DB-read-failure branch on the running-rows / known-sids query
(plan Step 6) is intentionally not exercised — there is no race-free
way to construct a DB that the flag-check query can read but the
subsequent rows query cannot without adding a fault-injection hook in
`Database::open` that exists only for this test. The branch is shallow
(log + early return) and the underlying failure mode is covered at the
DB layer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extract `_inner` helpers from every `interactive_*` Tauri command so
the bodies can be unit-tested with a borrowed `&AppState` instead of
booting a real Tauri runtime. Production entry points stay as thin
wrappers; `interactive_start` additionally takes an injectable
`HookForwardEmitter` callback (production wires it to `AppHandle::emit`,
tests pass a no-op) so the per-session hook-channel forwarder task
can be exercised without an `AppHandle`.
Add 18 new tests across `mod tests` covering every reachable branch of
the eight command handlers:
- `interactive_start_inner`
1. Happy path — synthesizes a `claudette-<short>-<rand>` sid,
calls `host.ensure_session` exactly once with the spec we
built, persists an `interactive_sessions` row in state
`"running"` with the right claude_args JSON, and populates
the sid→workspace_id reverse index.
2. Flag OFF — short-circuits with the canonical
"Claude Interactive is disabled" string before touching host
or DB.
3. Missing-CLI binary — surfaces "claudette-cli binary not found"
when neither `CLAUDETTE_CLI` nor a staged sidecar is on disk.
Pinned with a tolerant match arm because some dev workspaces
stage the sidecar inline next to the test binary.
- `interactive_send_input_inner` — happy path forwards `Text` payload
to the host with the exact bytes; missing-sid surfaces the
canonical "interactive session not found: <sid>"; flag OFF returns
the disabled error before touching the host.
- `interactive_capture_screen_inner` — happy path returns the host's
`ansi_bytes` base64-encoded AND persists the same raw bytes into
the DB row's `last_screen_blob`; missing-row tolerance pin (no
pre-existing row means the UPDATE returns `QueryReturnedNoRows`,
which the command must swallow so the capture itself still
succeeds); flag OFF returns the disabled error.
- `interactive_stop_inner` — graceful (`force=false`) maps to
`StopMode::Graceful`, DB row transitions to `"stopped"`, sid
mapping dropped; force (`force=true`) maps to `StopMode::Force`
with the same DB + sid cleanup; flag OFF + missing-sid surface
their canonical error strings.
- `interactive_list_for_workspace_inner` — populated case returns
only the rows for the queried workspace, newest-first by
`created_at`, with every field round-tripped through
`InteractiveSessionListItem`; empty/unknown workspace returns
`Ok(vec![])`.
- `interactive_list_orphans_inner` + `interactive_cleanup_orphans_inner`
— list returns every sid in the orphans map; cleanup drains the
map, calls `host.stop(sid, Graceful)` once per orphan, and returns
the stopped sids; cleanup on an empty map returns `Ok(vec![])`.
Two test mocks back the suite:
- `FakeInteractiveHost` records `ensure_session` / `send_input` /
`capture_screen` / `stop` calls and echoes a canned screen blob.
- `StopTrackingHost` is the specialized orphan-cleanup mock — records
each `stop()` invocation and `unreachable!()`s every other method
so a regression accidentally reaching for them fails loudly.
Process-global `$CLAUDETTE_HOME` and `$CLAUDETTE_CLI` mutations are
serialized through a `tokio::sync::Mutex` so the env-touching tests
don't race each other or sibling tests in the same binary.
Deferred branches:
- `interactive_host_for` failure: `select_default_host` is
infallible in practice (its sidecar branch constructs a
`SidecarHost` whose `new` cannot fail), and we already test the
pre-seeded happy path. Adding a fault-injection hook just to
cover the `map_err` is not worth the production surface change.
- `interactive_attach`: body is a one-liner over
`spawn_attach_forwarder`, which consumes a real `AppHandle::emit`
and requires a full Tauri runtime to exercise meaningfully. Same
rationale D1 used for the boot-reconciler emit path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…+ delete Adds Tauri-side coverage for the `stop_interactive_sessions_for_workspace_teardown` helper that `delete_workspace` and `archive_workspace_inner` both depend on: 1. The helper resolves the cached `InteractiveHost` and calls `stop(sid, Graceful)` for each live `interactive_sessions` row, skipping rows from sibling workspaces. 2. The sid -> workspace_id mappings in `AppState::interactive_sessions` are dropped after teardown so a stale lookup can't route a later command to a now-dead host. 3. `delete_workspace_with_summary` (the SQL that backs the delete path) actually cascades through `interactive_sessions` — pins the "DB row is gone" half of the delete contract. 4. The archive path leaves DB rows in place (no row delete -> no cascade), so the helper is the sole owner of host-side teardown there. 5. Empty-rows fast path: the helper skips host resolution entirely when no interactive sessions exist for the workspace, avoiding needless sidecar spawn / tmux probe on the overwhelmingly common delete/archive path. Coverage for the helper region in workspace.rs: 0/28 -> 19/28 lines hit. The 9 uncovered lines are all defensive error branches (Database::open, list_interactive_sessions_for_workspace, interactive_host_for failures) the helper logs-and-swallows, which mirrors the production "best effort" contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add focused tests for InteractiveTerminalMode that the existing happy-dom smoke test can't reach without mocking xterm: - Captures `term.onData` callback and asserts a single keystroke routes through the G3 `sendInput` service. - Stubs ResizeObserver to verify container reshapes re-run `fit.fit()`. - Pins the disposal order on unmount: ResizeObserver disconnect → onData disposable → terminal dispose. The audit calls out data-before-terminal as a correctness requirement. Coverage on InteractiveTerminalMode.tsx rises from 75.67% to 83.78% on statements and from 44.44% to 66.66% on functions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extracts the orphan-detected listener from App.tsx's god-effect into a sibling `<OrphanListener />` component so the lifecycle (subscribe → toast → cleanupOrphans → unlisten) can be tested in isolation without stubbing App's whole provider graph. Adds App.orphans.test.tsx covering toast emission, auto-cleanup invocation, unlisten-on-unmount, and the empty-sids no-op branch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add four targeted tests for the failure branches of the full-terminal interactive view: `attach` rejection on mount, `subscribeOutput` rejection, an unlisten function that throws on unmount, and the post-unmount race where `subscribeOutput` resolves after the effect has been torn down (verifies the resolved unlisten is invoked immediately so the listener never leaks). While wiring the tests in, fix two thin gaps in the production effect that the failure paths exposed: - `subscribeOutput` now has a `.catch` symmetric with the existing `attach` catch — previously a rejected subscribe would surface as an unhandled promise rejection instead of a logged warning. - Cleanup wraps `unlistenOutput()` in try/catch so a throwing listener teardown can't skip `term.dispose()` and leak the xterm instance + its DOM nodes. Coverage on `InteractiveTerminalMode.tsx` moves from 83.78/62.5/66.66/85.71 to 92.5/87.5/80/94.87 (stmts/branch/funcs/lines).
Flip both the Rust and the frontend coverage gates from informational
to blocking now that the interactive-claude patch surface measures
above the 85% threshold.
Rust producer now also runs against `claudette-session-host` so its
testable files (idle.rs, session.rs) contribute to the gate. Six files
are explicitly excluded from the gate with inline rationale in
`scripts/check-coverage-interactive.sh`:
- `interactive_host/{tmux,sidecar,conformance,mod}.rs` — host impls
and the shared conformance harness require external binaries
(tmux >= 3.0 or the spawned `claudette-session-host`) that CI
doesn't have. All real conformance tests are `#[ignore]`.
- `src-session-host/src/main.rs` — binary entry point covered by
integration tests.
- `src-session-host/src/server.rs` — request handlers require a
live `claude` PTY which CI doesn't have.
To pull `InteractiveSession::start` out of the uncovered set, add a
small RecordingHost mock and three focused tests (happy path, host
error propagation, overlay-materialize failure). claude_interactive.rs
goes from 82.39% -> 93.48%.
Frontend coverage already cleared the global thresholds but two files
were dragging individual numbers down; add focused tests:
- `InteractiveTerminalModeToggle.tsx` — six tests pinning the
interactive/no-session/no-workspace gates and the click toggle.
- `interactiveSessionsSlice.ts` — four tests pinning the setter,
clear, and reference-stable no-op behaviors.
Final numbers:
- Rust: 86.47% (1553/1796 lines across 8 included files)
- Frontend: 93.99% lines / 93.05% branches across 9 files
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…htened test, FIXME on SidecarHost reconnect - Drop vacuous expect(true).toBe(true) in InteractiveTerminalMode test; vitest treats no-throw as pass. - Tighten interactive_start_returns_error_when_cli_binary_missing by pointing CLAUDETTE_CLI at a guaranteed-nonexistent path and asserting only the Err branch — exercises the missing-CLI path deterministically on every runner (including CI machines with a staged sidecar). Also align bundled_cli_binary_path with its documented contract: return None when CLAUDETTE_CLI points at a path that does not exist. - Apply F5's .catch pattern to OrphanListener.tsx so a rejected subscribeOrphansDetected promise logs via console.warn instead of bubbling an unhandled rejection. Add a regression test that mocks the subscribe call to reject and asserts the warn is emitted. - Document the OnceCell<ConnHandle> reconnect limitation as a FIXME on SidecarHost.conn so a future reader hits the contract before debugging "conn closed" symptoms.
- Resolve clippy disallowed-methods by routing interactive host
command spawns through `claudette::process::{command, std_command}`.
- Add missing `ClaudeInteractive` arm to `AgentSession::start_compact`.
- Bump `claudette-session-host` to 0.25.0 to match the workspace.
- Adjust an Experimental settings test that hard-coded a single
switch — the rebased branch now adds the Claude (Interactive) row.
- Regenerate `Cargo.lock` for the new workspace member.
74ae4a5 to
3371659
Compare
db261e0 to
8d9720b
Compare
…tures Post-rebase: main added Workspace.input_values + Repository.required_inputs. Update the interactive-session and workspace test fixtures so the claudette-tauri test targets compile under CI's --no-run check.
|
Hey @codefriar — finished the rebase and got most of CI green. Posting a recap so you know what to expect when you pull. What I didRebased onto
Then four small fixes on top of the rebase:
Force-pushed with CIGreen after the four follow-ups: Lint, Format, Cargo Version Sync, Migration guard, Commit messages, PR title, Updater Manifest, Mobile (macOS), Desktop Tauri Check, Frontend, Frontend Bundle Smoke, build, codecov/patch + project. One flake —
Not blocking — happy to leave it as-is until you have a moment. Remaining gapsThe big one: after enabling the experimental flag, there's no UI path to actually select the Claude (Interactive) runtime. I traced it to the
Workarounds to try the feature today are both ugly: Smaller observations from sweeping the diff during conflict resolution:
Overall the architecture is clean — |
…mental flag Wire the last unconnected piece of the Claude (Interactive) PR (utensils#855) per @jamesbrink's review: enabling `claudeInteractiveEnabled` now actually surfaces a "Claude (Interactive)" option in the per-backend Runtime picker for the Claude-flavored kinds (Anthropic, Custom Anthropic, Codex Subscription), and selecting it persists via the existing `set_agent_backend_runtime_harness` command. Before this commit, the `effective_harness_kind` resolver already honored a persisted `runtime_harness = "claude_interactive"` value when the flag was on, but the Settings UI never offered the option because `availableHarnessesForKind` / `available_harnesses` never listed `ClaudeInteractive` — and the persistence validator rejected it. Approach - `src/agent_backend.rs`: add a sibling `available_harnesses_with_interactive(claude_interactive_enabled)` that wraps the static matrix and appends `ClaudeInteractive` for the three Claude-CLI-locked kinds when the flag is on. The static `available_harnesses` slice is unchanged so Pi-disabled downgrade fallback, gateway-hash key, and `effective_harness()` defense-in-depth filtering still see the matrix shape. `ClaudeInteractive` is intentionally not in the canonical matrix fixture — the gate is the experimental flag, not the per-kind allow-list. - `set_agent_backend_runtime_harness` reads the flag from `app_settings` via `state.claude_interactive_enabled()` and validates against the new sibling. Reading the flag server-side (not from the caller) prevents a stale frontend from tricking the validator while the gate is off. - TypeScript mirror: `availableHarnessesForKind(kind, options?)` gains the optional `claudeInteractiveEnabled` flag. Callers in `RuntimeSelector` thread it from the Zustand store; the `resolveSessionHarness` Pi-downgrade path keeps the matrix shape. - Drop the `TODO(G2 follow-up)` comment on the Models card in `ModelSettings.tsx` — selection now lives in `RuntimeSelector` and the comment was the last open question against this surface. Tests - Rust: 4 new unit tests in `agent_backend::tests` — flag-off back-compat (every kind), flag-on append for the three Claude kinds, flag-on no-op for every other kind, full round-trip through `effective_harness_kind` (override + flag on → ClaudeInteractive; override + flag off → kind default). - TypeScript: 4 new tests in `RuntimeSelector.test.tsx` (hide when off for Anthropic, render when on, persistence call on selection, never exposed for non-Claude kinds) plus 3 in `agentBackends.test.ts` pinning `availableHarnessesForKind`'s flag behavior. Refs: PR utensils#855, jamesbrink review feedback FB-1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…c gate Clarify in CLAUDE.md's project-structure list that tests/fixtures/stub-tui is relied on by BOTH the claudette lib's interactive tests AND the claudette-session-host integration tests (attach_stream / ensure_session / handshake). Note on the version-sync gate: scripts/check-cargo-version-sync.sh already discovers workspace members dynamically via `cargo metadata`, so no change is needed there — src-session-host is included automatically and the check passes at 0.25.0 across all 7 packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…eflake lag test
The `attach_lagged_subscriber_stream_ends` test flaked once on Linux CI
under `cargo llvm-cov`, running its full 10s `drain_until_contains` budget
without ever observing `READY`. Root cause: a tokio broadcast join race.
The stub-tui prints `READY` on startup before any attach client has
subscribed to the per-session `broadcast::Sender<SessionEvent>`. Under
llvm-cov instrumentation the PTY reader can broadcast `READY` to zero
subscribers, the message is dropped, and the slow drainer that subscribes
moments later never sees it.
Fix: add a `STUB_TUI_DELAY_MS` env var to the stub-tui that sleeps before
the initial `println!("READY")`. Only the lag test sets it (to 200 ms),
so `attach_streams_echoed_output` and other tests keep their fast-start
path. The 10s timeout widening from 0bffca1 stays as belt-and-suspenders.
Verified 10/10 sequential runs of the lag test pass; full session-host
suite green; clippy + fmt clean; interactive coverage gate still passes
at 85.75% / 85%.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks for the rebase + the detailed write-up @jamesbrink — that was a big help. Four follow-up commits on top of your rebase address each item: The blocker — runtime selection now works
The Models card with Smaller items
CoverageThe patch-coverage gate still passes after all changes:
Ready for your end-to-end smoke whenever you have a moment. |
Summary
Adds a new experimental agent backend
ClaudeInteractivethat runs the interactiveclaudeTUI (no--print) inside a detachable host process that outlives Claudette. Coexists with the existingclaude --printflow; selectable per-workspace; gated behind aclaudeInteractiveEnabledexperimental flag (default off, no impact on existing users).Closes #838 (proposal issue).
What you get
claudette-session-hostRust sidecar on Windows (and opt-in Unix fallback).tmux attach. Sidebar context menu copiestmux attach-session -t claudette-<sid>so you can drive the same session from any terminal.What it is NOT
claude --printpath. That code is untouched.ClaudeInteractiveis added alongside as a newAgentHarnessKind.claudeInteractiveEnabledin Settings → Experimental.Architecture
Hook plumbing. Each session gets a transient
CLAUDE_CONFIG_DIRoverlay registering three Claude Code hooks (Stop,Notification,UserPromptSubmit) whose commands invokeclaudette-cli chat hook --sid <SID> --kind <kind>. The CLI sends the event over the existing local IPC socket, which the GUI ingests via a newchat_hookmethod that routes to a per-session channel. Hook events drive the awaiting-input badge, turn delimiters, and OS notifications. No new transport.Key crates and files.
src-session-host/— the bundled sidecar binary.src/agent/interactive_host/—InteractiveHosttrait +TmuxHost+SidecarHost+ conformance suite + availability check.src/agent/interactive_protocol.rs— wire types (length-prefixed JSON envelope frames).src/agent/claude_interactive.rs—InteractiveSession::start+SettingsOverlay.src/interactive.rs— lib-level lifecycle helpers (reattach_pending,stop_sessions_for_workspace,detect_orphans).src-tauri/src/commands/interactive.rs— 6 Tauri commands + 2 orphan commands.src-tauri/src/interactive_lifecycle.rs— boot reconciler.src-cli/src/commands/chat_hook.rs—claudette chat hooksubcommand.InteractiveTurnView,InteractiveTurns,InteractiveTerminalMode*,useInteractiveTurnAssembler,useInteractiveChatMode,services/interactive.ts,InteractiveBadge.src/migrations/20260517020158_interactive_sessions.sql.site/src/content/docs/features/interactive-claude.mdx,settings.mdxrow, CLAUDE.md + Copilot mirror.Complexity Notes
Things reviewers should look at carefully:
SidecarHost(src/agent/interactive_host/sidecar.rs). The plan called for a single multiplexed connection, but the session-host'shandle_connectionbecomes single-purpose after Attach (the dispatch loop exits), so multiplexing on one socket is structurally impossible. The implementation uses one long-lived control connection (request-ID correlated viaRequestEnvelope/InboundFrame) and a fresh socket perattach()call. Detach = client closes the connection.!Sendfutures aroundrusqlite::Connection. Several lifecycle helpers (reattach_pending,stop_sessions_for_workspace) hold a&Databaseacross ahost.status().await. The Tauri-side glue routes throughspawn_blocking+current_threadTokio runtime so the multi-thread runtime never has to park a!Sendfuture. Seesrc-tauri/src/interactive_lifecycle.rsand the per-workspace teardown insrc-tauri/src/commands/workspace.rs.src/agent/interactive_host/tmux.rs). A FIFO has a single in-kernel reader buffer — multiple readers would split bytes, not duplicate. Implementation: one per-session blocking reader spawns atensure_session, pushing into atokio::sync::broadcast.attach()calls.subscribe()and bridges to an mpscReceiverStream. EOF detection runs a synchronoustmux has-sessionprobe inside the blocking thread to emitAttachEvent::Exitcleanly."running" | "detached" | "stopped" | "crashed" | "unknown"(the TypeScriptInteractiveSessionStateunion mirrors them). The DB column isTEXT; an early draft used"exited"which has been globally replaced.AttachEvent::Hook(HookFired)(typed, nested) while the CLI-relayed path emits flat{sid, kind, reason}(synthesized byclaudette-cli chat hook). Both end up on the sameinteractive://<sid>/hookTauri event topic.normalizeHookPayloadinservices/interactive.tscollapses both shapes into a singleHookEvent. Tests cover both directions.'...'quoting on Unix and"..."doubling on Windows (shell_quoteinclaude_interactive.rs,#[cfg]-gated). Same overlay format, platform-correct invocation.SidecarHosthas no reconnect path. The cachedConnHandleinOnceCellis never reset if the connection dies (e.g., 600s idle-exit of the sidecar). Subsequentinteractive_*commands fail with "conn closed" until Claudette restarts. Documented in CLAUDE.md as a known limitation; tracked as follow-up work.Test Steps
Automated
Manual
tray.rspath).kill -9Claudette so the cleanup teardown can't run, reopen Claudette. The orphan should be detected on boot, a toast appears, and the session is auto-stopped.--printflow is unchanged: pick a workspace with the default Claude runtime, send a turn, verify the old chat UI still renders identically.Coverage
A per-file coverage gate (
scripts/check-coverage-interactive.sh+vitest.config.tsthresholds) now enforces >=85% on the patch surface. Plan + tasks are insuperpowers/plans/2026-05-18-interactive-claude-coverage-plan.md.Final numbers
Rust (
cargo llvm-cov, scoped to the patch set):interactive_protocol.rs98.73%,claude_interactive.rs93.48%,interactive_sessions.rs~95%,interactive.rs84.39%#[ignore]d (require tmux >= 3.0 / spawned binary)Frontend (
vitest run --coverage, istanbul, 9-file include):InteractiveBadge.tsx,interactiveSessionsSlice.ts,InteractiveTerminalModeToggle.tsx,InteractiveTurns.tsx,useInteractiveChatMode.tsall at 100%Documented exclusions (structurally CI-untestable)
The Rust gate excludes six files whose tests require either
tmux >= 3.0on the runner, a liveclaudesubprocess, or a spawned sidecar binary:interactive_host/{tmux,sidecar,conformance,mod}.rs,src-session-host/src/{main,server}.rs. Rationale lives inline inscripts/check-coverage-interactive.sh. The gated surface is 1796 of 2914 total patch lines (62%); the remainder is exercised by#[ignore]d conformance tests run manually on dev machines.Defects found while raising coverage
Three small production defects surfaced during the coverage push and were fixed in the same series:
InteractiveTerminalMode.tsx—subscribeOutputhad no.catch, surfacing as an unhandled rejection onlisten()failure (asymmetric with the siblingattachpath).InteractiveTerminalMode.tsx— throwingunlistenOutput()skippedterm.dispose(), leaking the xterm DOM instance.services/interactive.ts— flat-path unknown hook kinds discarded the original kind label (caught earlier during G3 review).Tests added during coverage work
~50 new tests across 9 phases (A baseline → B Rust lib → C session-host → D Tauri → E CLI → F frontend → G enforce). Headline coverage deltas:
sidecar.rs: 0% → 50%+ lines (ConnHandlefault paths)interactive_protocol.rs: 80% → 98.73% lines (frame edge cases)commands/interactive.rs: 0% → 82.93% lines (8 commands × inner-helper extraction for testability)interactive_lifecycle.rs: 0% → 6 of 7 branches covered (boot reconciler)InteractiveTerminalMode.tsx: 77% → 86% lines (keystrokes + ResizeObserver + rejection paths)useInteractiveTurnAssembler.ts: 87% → 94% branches (race conditions)Checklist
claudette,claudette-cli,claudette-tauri,claudette-session-host, andsrc/ui(vitest)site/src/content/docs/features/interactive-claude.mdx+settings.mdxrow + CLAUDE.md /.github/copilot-instructions.mdsynced20260517020158_interactive_sessions.sql, registered inMIGRATIONS)claude --printpath (existing tests untouched, default flag off)#[cfg]gating verified)