docs(interactive): spec + plan for in-app interactive sessions
Replace the external wt.exe 'Run interactively' launch with an in-app streaming chat (persistent claude --input-format stream-json), rendered in the shared SessionTerminalView in task detail and Mission Control. Autonomous task execution is untouched. Mid-turn interrupt+redirect verified against CLI 2.1.191 via spike.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user