# In-App Interactive Sessions — Design

**Date:** 2026-06-26
**Status:** Proposed (awaiting approval)

## Goal

Replace the external Windows-Terminal "Run interactively" session with an **in-app
streaming chat**, rendered in the existing `SessionTerminalView` in **both task detail and
Mission Control**. Keep everything inside the app — no `wt.exe` pop-out. Autonomous task
execution is **untouched** (stays one-shot, non-interactive).

## Decisions (brainstorm)

1. **Engine: persistent streaming session.** One `claude` process kept alive with
   `--input-format stream-json`; user messages pushed over stdin.
2. **Scope: interactive sessions only.** The autonomous `TaskRunner`/`ClaudeProcess` run
   loop, review, queue, and worktree machinery are NOT changed.
3. **Placement: shared `SessionTerminalView`** — the in-app session + composer appear in the
   task-detail session surface and in the Mission Control monitor pane.
4. **Full replace.** "Run interactively" now opens the in-app session; the
   `WindowsTerminalLauncher.LaunchInteractiveAsync` path is removed. **Planning** sessions
   keep using `wt` (untouched).
5. **Send semantics: interrupt + redirect** mid-turn (control protocol), with automatic
   *queue-for-next-turn* fallback if interrupt is unavailable.

## What an interactive session is (unchanged semantics, new transport)

Today (`PlanningSessionManager.OpenInteractiveAsync` + `WindowsTerminalLauncher`):
`claude --model <PlanningAlias> --permission-mode auto "<task title+description>"` in the
**list's working dir**, env `MAX_THINKING_TOKENS=20000`, full default toolset, relies on the
globally-registered `claudedo` MCP. **Ephemeral** — no worktree, no `task_run` record, no
status change, no review.

We keep all of that. Only the transport changes: instead of a `wt` window, the same
`claude` invocation runs as a persistent stream-json process owned by the worker, its output
streamed into the app and its stdin fed from an in-app composer.

> Honest tradeoff: the `wt` terminal gave the full Claude Code TUI (slash-command UX,
> interactive prompts). An in-app stream-json chat is plainer — type messages, watch streamed
> output. `--permission-mode auto` means no blocking permission prompts (so headless works),
> but it is a simpler surface than the real TUI. Accepted per the "full replace" decision.

## The streaming engine

Flags: `--model <PlanningAlias> --permission-mode auto --input-format stream-json
--output-format stream-json --verbose --replay-user-messages` in the list working dir, env
`MAX_THINKING_TOKENS=20000`. No `--mcp-config`/`--allowedTools` (interactive uses the global
MCP + default tools, exactly as today).

- First stdin message = the seeded interactive prompt:
  `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"…"}]},"parent_tool_use_id":null}\n`
  (stdin stays open).
- A stdout read task forwards each NDJSON line to a callback (→ broadcast + the session's log)
  and detects `result` events (turn boundary; the process then idles for the next message).
- `SendUserMessageAsync(text)` writes a user-message JSON line; if a turn is in flight, also
  `InterruptAsync()` (control-protocol interrupt) so Claude pivots immediately. If interrupt
  is unavailable, the message lands when the current turn ends → automatic queue fallback.
- **Interrupt is verified working** (spike, 2026-06-26, CLI 2.1.191). Exact shape:
  `{"type":"control_request","request_id":"<id>","request":{"subtype":"interrupt"}}` — no
  `initialize` handshake needed; `control_response {"subtype":"success"}` confirms
  synchronously; the same process then accepts the redirect and runs a fresh turn with
  context intact.
- **Interrupt artifact:** the aborted turn emits a `result` with `is_error=true,
  subtype="error_during_execution"`. The session must treat an interrupt-induced result as
  *"turn aborted, continue"* (drain the queued redirect), **not** as a session failure.
  Tolerate the incidental `system:init`/`system:status`/`rate_limit_event`/hook events that
  also appear in the stream.
- `--replay-user-messages` echoes each sent message back on stdout as a `user` event, so it
  rides the existing stream pipeline into the timeline (ordered + confirmed) with no extra
  broadcast surface.
- The session ends only when the **user stops it** (kill the process tree) — an interactive
  session has no auto-finalize and never enters review. No queue slot is involved (it is
  launched directly, not via the autonomous picker).

## Surface changes

**Worker**
- `Runner/StreamingClaudeSession.cs` (new) — persistent process + send/interrupt/stop; reuse
  the `ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT` from `ClaudeProcess`; streams via a line
  callback; `IsTurnInFlight`. Cancellation kills the tree.
- `Runner/LiveSessionRegistry.cs` (new, singleton) — `taskId → StreamingClaudeSession`
  (`Register`/`TryGet`/`Unregister`/`Stop`), mirrors `PendingQuestionRegistry`.
- `Planning/InteractiveSessionService.cs` (new) — owns interactive lifecycle: `StartAsync(
  taskId)` resolves the list working dir + seeded prompt (reuse `OpenInteractiveAsync`'s
  body), spawns the session, registers it, wires output to `HubBroadcaster.TaskMessage`,
  broadcasts `InteractiveSessionStarted`; `SendAsync(taskId, text)`; `StopAsync(taskId)` →
  `InteractiveSessionEnded`.
- `Planning/WindowsTerminalLauncher.cs` + `Planning/Interfaces/ITerminalLauncher.cs` — remove
  `LaunchInteractiveAsync` (+ `InteractiveLaunchContext`). Planning start/resume stay.
- `Hub/WorkerHub.cs` — `OpenInteractiveTerminalAsync` re-pointed to
  `InteractiveSessionService.StartAsync` (no terminal); add `SendInteractiveMessage(taskId,
  text)`, `StopInteractiveSession(taskId)` (+ optional `InterruptInteractiveSession`).
- `Hub/HubBroadcaster.cs` — `InteractiveSessionStarted(taskId)`,
  `InteractiveSessionEnded(taskId)`. Log lines reuse the existing `TaskMessage(taskId, line)`.
- `Program.cs` — register `LiveSessionRegistry` + `InteractiveSessionService`.

**UI**
- `Views/Islands/SessionTerminalView.axaml(.cs)` — add an optional composer (styled
  properties: `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`).
  Both hosts (task detail + Mission Control) get it by binding their VM's composer state.
- `StreamLineFormatter` — render `type:"user"` NDJSON events as a `LogKind.User` bubble.
- A small shared composer concept on `TaskMonitorViewModel` **and** `DetailsIslandViewModel`
  (factor a helper to avoid duplication): `ComposerDraft`, `SubmitComposerCommand`,
  `IsInteractiveLive` (set by `InteractiveSessionStarted/Ended`). Submit →
  `SendInteractiveMessageAsync`; clear draft. (If a pending AskUser question exists, the same
  composer answers it — keep the existing answer route.)
- `MissionControlViewModel` — `EnsureMonitor(taskId)` on `InteractiveSessionStarted` so the
  session appears as a monitor; mark it interactive.
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs` — `SendInteractiveMessageAsync`,
  `StopInteractiveSessionAsync` (+ optional interrupt); events
  `InteractiveSessionStartedEvent`/`InteractiveSessionEndedEvent`. `OpenInteractiveTerminalAsync`
  keeps its name/signature (now starts the in-app session). Update hand-rolled fakes in **both**
  test projects (`iworkerclient_fakes_sync`).
- `TasksIslandViewModel.RunInteractivelyAsync` — unchanged call site; now opens/focuses the
  in-app session surface instead of a terminal.
- Localization `interactive.*` / `missionControl.chat.*` (en/de, parity enforced).

**Tests**
- `StreamingClaudeSessionTests` (fake process stream, no real Claude): first message streams;
  `result` idles; a sent message starts another turn; mid-turn send calls `InterruptAsync`
  then delivers; interrupt-failure degrades to queue; stop kills.
- `LiveSessionRegistryTests` — register/get/unregister/stop.
- `InteractiveSessionServiceTests` — start resolves working dir + seeds prompt + registers +
  broadcasts started; send routes to the session; stop broadcasts ended (fake session +
  broadcaster).
- `TaskMonitorViewModelTests` / `DetailsIslandViewModelTests` — composer enabled while
  interactive-live; submit invokes client + clears; `user` line renders; question route still
  answers.

## Risks / open questions

- **Interrupt protocol shape — RESOLVED** (spike 2026-06-26, see "The streaming engine").
  Mid-turn interrupt works on CLI 2.1.191 with the documented shape; the queue fallback is a
  genuine fallback now, not the expected path. Re-verify if the CLI version changes.
- **Plainer than the TUI** — slash-command/interactive-prompt UX differs (accepted).
- **Auto-mode editing the list working dir directly** (no worktree) — this is the *existing*
  interactive behavior, unchanged here.
- **No real-Claude tests** (project rule) — the live loop is covered only by the fake stream;
  real interrupt/redirect is a **manual verification gap** to flag.

## Non-goals

- Changing autonomous task execution / review / queue / worktrees.
- Interactive sessions producing run records, worktrees, or review (stays ephemeral).
- Worktree isolation for interactive edits; image/attachment messages in the composer.
- Removing planning's `wt` terminal launch.
