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:
101
docs/superpowers/plans/2026-06-26-in-app-interactive-sessions.md
Normal file
101
docs/superpowers/plans/2026-06-26-in-app-interactive-sessions.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Plan — In-App Interactive Sessions
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-26-in-app-interactive-sessions-design.md`
|
||||
|
||||
Implement on the shared main tree. Commit explicit paths per task (never `git add -A`).
|
||||
Build with `-c Release` (running Worker locks Debug). No real-Claude tests — fake the
|
||||
process stream. Sonnet subagents. Autonomous `TaskRunner`/`ClaudeProcess` path stays untouched.
|
||||
|
||||
## Task 1 — StreamingClaudeSession (worker, new file)
|
||||
- `Runner/StreamingClaudeSession.cs`: persistent `claude` process. Ctor takes resolved args,
|
||||
working dir, seeded first prompt, a line callback, `WorkerConfig`. Reuse the
|
||||
`ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT="200000"` from `ClaudeProcess`.
|
||||
- Keeps stdin open; sends the first prompt as a user-message JSON line (escape via
|
||||
`JsonSerializer`).
|
||||
- stdout/stderr read tasks → line callback; parse `result` events to track `IsTurnInFlight`.
|
||||
- `SendUserMessageAsync(text, ct)` — enqueue/write a user-message JSON line; if
|
||||
`IsTurnInFlight`, also `InterruptAsync`.
|
||||
- `InterruptAsync(ct)` — write the control-protocol interrupt line; best-effort (swallow +
|
||||
log on failure → queue fallback applies).
|
||||
- `StopAsync` / `DisposeAsync` — close stdin, kill the tree, await exit.
|
||||
- Injectable stream seam so a fake can drive it without a real `claude` binary.
|
||||
- Test: `StreamingClaudeSessionTests` (fake stream) — first message emitted; `result` flips
|
||||
`IsTurnInFlight` off; a sent message produces a second turn; mid-turn send calls interrupt
|
||||
then delivers; interrupt throw → delivered at natural turn end; stop kills.
|
||||
|
||||
## Task 2 — LiveSessionRegistry (worker, new file)
|
||||
- `Runner/LiveSessionRegistry.cs`: singleton; `Register(taskId, StreamingClaudeSession)`,
|
||||
`bool TryGet(taskId, out session)`, `Unregister(taskId)`, `Task StopAsync(taskId)`.
|
||||
- Test: register→get; unregister; second register stops+replaces; missing get returns false.
|
||||
|
||||
## Task 3 — InteractiveSessionService (worker, new file)
|
||||
- `Planning/InteractiveSessionService.cs`: inject `IDbContextFactory`, `WorkerConfig`,
|
||||
`ClaudeArgsBuilder` (or build args inline), `HubBroadcaster`, `LiveSessionRegistry`.
|
||||
- `StartAsync(taskId, ct)`: resolve list working dir + seeded prompt (reuse the body of
|
||||
`PlanningSessionManager.OpenInteractiveAsync` + `BuildInteractivePrompt`); build interactive
|
||||
args (`--model PlanningAlias --permission-mode auto` + streaming flags); spawn the session
|
||||
with a callback that does `HubBroadcaster.TaskMessage(taskId, "[stdout] " + line)`;
|
||||
register; broadcast `InteractiveSessionStarted`. Reject if one is already live for the task.
|
||||
- `SendAsync(taskId, text, ct)` → registry `TryGet` → `SendUserMessageAsync`.
|
||||
- `StopAsync(taskId, ct)` → registry stop + `InteractiveSessionEnded`.
|
||||
- Move `OpenInteractiveAsync`/`BuildInteractivePrompt` out of `PlanningSessionManager` if it
|
||||
reads cleaner (or call into it). Remove the `InteractiveLaunchContext` terminal coupling.
|
||||
- Test: `InteractiveSessionServiceTests` (fake session factory + fake broadcaster) — start
|
||||
resolves dir, seeds prompt, registers, broadcasts started; missing working dir throws;
|
||||
send routes; stop broadcasts ended.
|
||||
|
||||
## Task 4 — Remove terminal interactive path (worker)
|
||||
- `Planning/Interfaces/ITerminalLauncher.cs` + `WindowsTerminalLauncher.cs`: delete
|
||||
`LaunchInteractiveAsync`; remove `InteractiveLaunchContext` from `PlanningSessionContext.cs`.
|
||||
Keep planning start/resume launches.
|
||||
- Fix any references; ensure the planning launcher tests still build.
|
||||
|
||||
## Task 5 — Hub + Broadcaster + DI (worker)
|
||||
- `Hub/WorkerHub.cs`: re-point `OpenInteractiveTerminalAsync` to
|
||||
`InteractiveSessionService.StartAsync` (drop `_launcher.LaunchInteractiveAsync`); add
|
||||
`Task SendInteractiveMessage(taskId, text)`, `Task StopInteractiveSession(taskId)`
|
||||
(+ optional `InterruptInteractiveSession`).
|
||||
- `Hub/HubBroadcaster.cs`: `InteractiveSessionStarted(taskId)`, `InteractiveSessionEnded(taskId)`.
|
||||
- `Program.cs`: register `LiveSessionRegistry` + `InteractiveSessionService` singletons.
|
||||
- Test: `WorkerHub` send routes to a fake service; start invokes the service.
|
||||
|
||||
## Task 6 — UI client + fakes (ui)
|
||||
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs`: `SendInteractiveMessageAsync(
|
||||
taskId, text)`, `StopInteractiveSessionAsync(taskId)` (+ optional interrupt); events
|
||||
`Action<string>? InteractiveSessionStartedEvent`, `InteractiveSessionEndedEvent` with
|
||||
`On<...>` handlers. `OpenInteractiveTerminalAsync` keeps name/signature.
|
||||
- Update hand-rolled `IWorkerClient` fakes in **both** Ui.Tests and Worker.Tests.
|
||||
|
||||
## Task 7 — StreamLineFormatter user bubble (ui)
|
||||
- Render `type:"user"` NDJSON events as `LogKind.User` (add the kind if missing).
|
||||
- Test: a `user` event yields a `LogKind.User` `LogLineViewModel` with the text.
|
||||
|
||||
## Task 8 — Shared composer state on the session VMs (ui, hot files)
|
||||
- Add to `TaskMonitorViewModel` and `DetailsIslandViewModel` (factor a shared helper —
|
||||
`InteractiveComposer` — to avoid duplication): `ComposerDraft`, `IsInteractiveLive`
|
||||
(toggled by `InteractiveSessionStarted/Ended` for the subscribed task),
|
||||
`SubmitComposerCommand` (CanExecute: non-empty draft && (`HasPendingQuestion` ||
|
||||
`IsInteractiveLive`)). Route: pending question → existing `AnswerTaskQuestionAsync`; else →
|
||||
`SendInteractiveMessageAsync`. Clear draft on submit; clear `IsInteractiveLive` on ended.
|
||||
- `MissionControlViewModel`: `EnsureMonitor(taskId)` on `InteractiveSessionStarted`.
|
||||
- Test: composer enabled while interactive-live; submit routes (chat vs answer) + clears;
|
||||
ended clears live state.
|
||||
|
||||
## Task 9 — SessionTerminalView composer (ui)
|
||||
- `Views/Islands/SessionTerminalView.axaml(.cs)`: optional composer docked bottom (styled
|
||||
props `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`); TextBox
|
||||
(Enter submits) + Send button. Reuse existing tokens (no inline values).
|
||||
- Bind it in `MonitorPaneView.axaml` and `DetailsIslandView.axaml` to each VM's composer
|
||||
state. Fold the existing AskUser banner into the composer's "answering" state if it reads
|
||||
cleaner; otherwise leave the banner and add the composer below.
|
||||
|
||||
## Task 10 — Localization
|
||||
- `en.json` + `de.json`: `interactive.composer.placeholder`, `.send`, `.stop`, plus any
|
||||
"session ended" notice. Keep parity (Localization.Tests).
|
||||
|
||||
## Task 11 — Build + test + verify
|
||||
- Build App + Worker `-c Release`; run Worker.Tests, Ui.Tests, Localization.Tests.
|
||||
- Self-review diffs. **Manual smoke (real CLI) — flag to Mika:** (a) Run interactively opens
|
||||
an in-app chat (no terminal) and streams; (b) sending a message mid-turn interrupts +
|
||||
redirects; (c) stop kills the process; (d) session shows in both task detail and Mission
|
||||
Control. Do not push.
|
||||
Reference in New Issue
Block a user