# ConPTY Interactive Sessions — Design Date: 2026-07-23 Status: Approved (design), implementation not started ## Problem The current in-app interactive session path streams `stream-json` from a `claude` process spawned **in the Worker** and renders it as a chat log with a composer. It does not surface permission requests, AskUser questions, and other TUI-native interactions well — the rendering is a partial reimplementation of what the real Claude Code TUI already does. We want full fidelity for interactive work without rebuilding the TUI. ## Decision **Hybrid execution model:** - **Autonomous queue tasks** (`Status=Queued`, picked by the queue): unchanged. Headless `stream-json`, full orchestration (status flow, diff, review, merge). - **Interactive sessions**: an **embedded ConPTY terminal** running the real `claude` CLI, rendered in the **UI process**. Full TUI fidelity (permission prompts, questions, colors, everything the standalone CLI does). Detached from the review/merge/status machinery — these are a manual cockpit. This is the third direction for interactive (external `wt` terminal → streaming chat → embedded ConPTY). The streaming interactive stack is **removed**, not run in parallel — accepted as discarded work in exchange for one interactive path and full fidelity. ## Architecture ### Process location ConPTY terminal controls render in-process and spawn their child (`claude`) as a child of the host process. Therefore interactive sessions move **out of the Worker and into the UI process**. They no longer flow over SignalR. This mirrors the existing `ResumeTaskInTerminal` behavior (launch real `claude`), but embedded instead of via external `wt.exe`. ### Worktree preparation (task-based sessions) Interactive task sessions get the **same worktree preparation as autonomous runs**: session-skills seeding, agent files, MCP config, environment. The Worker performs the prep and returns a launch spec to the UI: ``` LaunchSpec { cwd: string // worktree path exe: string // resolved claude executable / shell args: string[] // e.g. --resume env: Dictionary } ``` The command construction reuses `WindowsTerminalLauncher.BuildResumeArgs`/`Resolve`. Guards mirror `ResumeTaskInTerminal` for Running/Queued (rejected). Worktree handling (FINAL): - Existing Active/Kept worktree + persisted SessionId → `--resume `. - No usable worktree but the list has a WorkingDir (git repo) → create a worktree **on demand** via `WorktreeManager.CreateAsync` (the same path autonomous runs use), then a fresh-start spec. This lets never-run tasks be opened interactively. - Fresh (non-resume) session → the task's prompt (title + description) is passed as claude's positional prompt so the session starts on the task. - No worktree and no WorkingDir → clear error. Interactive sessions are detached: ClaudeDo does NOT record their claude session id (ConPTY is opaque, no stream-json), so resuming a specific past interactive conversation is only via claude's own `--continue`/`--resume` in the worktree dir. ### Free / ad-hoc sessions In addition to task-based sessions, the user can open an ad-hoc terminal in a chosen directory (no task). These also get MCP config + env set up so the `claudedo` tools are available, but no per-level session-skills seeding tied to a task. ### Terminal host control — RESOLVED by spike (2026-07-23) **Library: `Iciclecreek.Avalonia.Terminal` 2.0.3** (namespace `Iciclecreek.Terminal`). It needs Avalonia >= 12.0.2; the repo is on 12.0.4 → compatible, no bump. `SvcSystems.UI.Terminal` (latest) needs Avalonia 12.1+ → rejected. Visual pass (user, 2026-07-23): the real `claude` TUI renders correctly inside the embedded control — Claude Code opened and was usable. **Binding approach — use the library's own `LaunchProcess()` (FINAL).** An initial attempt drove `Porta.Pty` ourselves (own read loop, key tunneling via `GenerateKeyInput`/`GenerateCharInput`, manual resize) to bypass `LaunchProcess()` and inject a custom `PtyOptions.Environment`. That was a mistake: it rendered wrong, lagged, and dropped input. The spike had proven the library's own `TerminalControl.LaunchProcess()` pipeline renders correctly, stays responsive, and handles input/resize/focus. So `PtyTerminalSession` is a thin wrapper (`src/ClaudeDo.Ui/Services/PtyTerminalSession.cs`): - Set `control.Process = descriptor.Exe`, `control.Args = descriptor.Args`, `control.StartingDirectory = descriptor.Cwd`, then `await control.LaunchProcess()`. - Relay the control's own `ProcessExited` event and `Kill()`. - `Process=""` stays on the AXAML `TerminalControl` to suppress the library's auto-launch-on-load, so exactly one process starts (our manual launch). **Environment:** `LaunchProcess()` gives no per-launch env dict, but `Porta.Pty.SpawnAsync` inherits the *current process* environment. So apply `descriptor.Env` entries via `Environment.SetEnvironmentVariable(key, value)` (process scope) before `LaunchProcess()`. The only var needed is `MCP_TOOL_TIMEOUT`; session-skills/agent-files/MCP-config are on disk / globally registered, independent of env. (The earlier "child gets zero env vars" claim was wrong — Porta.Pty seeds from the process env.) **Sizing gotcha (critical):** the control derives Cols/Rows from `arranged-size / character-cell-size`. Without an EXPLICIT monospace font the cell metrics are wrong and the child TUI renders into the wrong area. Set `FontFamily="Cascadia Mono,Consolas,monospace"`, `FontSize`, `BufferSize`, and `HorizontalAlignment/VerticalAlignment=Stretch` on the `TerminalControl` (matching the spike) — this is what made rendering correct in the pane. **Lesson:** do not hand-roll pty/input/render around this control — use its `LaunchProcess()` pipeline. ### Command Center layout - `MonitorPaneView` keeps the streamed log for autonomous tasks. - Interactive panes host the terminal control instead of the log+composer. - Layout is **toggleable**: focus mode (tabs, one session large) ↔ overview mode (grid, several sessions at once). ## Removals Worker: - `StreamingClaudeSession`, `InteractiveSessionService` - `WorkerHub` interactive methods: `OpenInteractiveTerminal`, `SendInteractiveMessage`, `RemoveQueuedInteractiveMessage`, `StopInteractiveSession`, `InterruptInteractiveSession` - Broadcast events: `InteractiveSessionStarted/Ended`, `InteractiveQueueChanged`, `InteractiveMessageSent` - `IdleSessionReaper` and `LiveSessionRegistry` **iff** unused elsewhere (verify during implementation — do not delete blindly). UI: - Composer on `TaskMonitorViewModel`: `ComposerDraft`, `SubmitComposerCommand`, `InterruptInteractiveCommand`, `StopInteractiveCommand`, `QueuedMessages`, `IsInteractiveLive`. - The composer + queued-messages portion of `SessionTerminalView` (the log portion stays for autonomous panes). - `IWorkerClient` interactive methods. ## Open items (implementation time) - Whether `LiveSessionRegistry` is referenced outside the interactive path. - Session-id availability for a never-run task (no `--resume` → start fresh). Resolved: env approach (see Terminal host control — add custom vars on top of the inherited process env via `PtyOptions.Environment`); library + binding seam. ## Non-goals - No screen-scraping of terminal output back into task status/diff/review. - No change to the autonomous queue execution path. ## Status (2026-07-23) Implemented on main (not pushed) and visually verified by the user (rendering, input, responsiveness correct after switching to `LaunchProcess()` + setting a monospace font). Done: worker launch-spec (task + ad-hoc, on-demand worktree, prompt seeding), UI terminal host, Command Center hosting (grid↔tabs), streaming stack removed. Permission mode: left to claude's default (not forced), per user. Deferred: **Avalonia 12.1 upgrade** — blocked, not adopted. 12.1's XAML source generator needs Roslyn 4.14 (.NET 9.0.3xx SDK); the repo pins .NET 8 in `global.json`, and bumping the SDK floor risks the Gitea Actions release build. Staying on Avalonia 12.0.x (Iciclecreek 2.0.3 works there). Revisit only with a deliberate SDK-floor decision.