diff --git a/docs/superpowers/plans/2026-06-26-in-app-interactive-sessions.md b/docs/superpowers/plans/2026-06-26-in-app-interactive-sessions.md new file mode 100644 index 0000000..7f6e1b6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-in-app-interactive-sessions.md @@ -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? 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. diff --git a/docs/superpowers/specs/2026-06-26-in-app-interactive-sessions-design.md b/docs/superpowers/specs/2026-06-26-in-app-interactive-sessions-design.md new file mode 100644 index 0000000..cb780d3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-in-app-interactive-sessions-design.md @@ -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 --permission-mode auto ""` 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 --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":"","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.