6.2 KiB
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. Headlessstream-json, full orchestration (status flow, diff, review, merge). - Interactive sessions: an embedded ConPTY terminal running the real
claudeCLI, 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 <sessionId>
env: Dictionary<string,string>
}
The command construction reuses the existing logic in
WindowsTerminalLauncher.BuildResumeCommand. Guards mirror
ResumeTaskInTerminal: task not Running/Queued, a persisted SessionId exists,
worktree exists and is Active/Kept.
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 — drive Porta.Pty ourselves, reuse Iciclecreek's VT engine.
The convenience TerminalControl.LaunchProcess() spawns the child itself and
never sets PtyOptions.Environment, and there is no settable pty/env seam on
TerminalControl/TerminalView (env-building lives in the non-virtual
TerminalView.LaunchProcess() using private fields). But the VT parser/renderer
is the public XTerm.Terminal object exposed via TerminalControl.Terminal, so
we reuse it without forking:
Porta.Pty.PtyProvider.SpawnAsync(new PtyOptions { Name, App, Cwd, CommandLine, Environment = <fully populated dict> }, ct)→IPtyConnection.- Do not call
TerminalControl.LaunchProcess()(leaves its_ptyConnectionnull). - Get
TerminalControl.Terminal(public,XTerm.Terminal) after template apply. - Pump
IPtyConnection.ReaderStream→Terminal.Write(text); subscribeTerminal.DataReceived→IPtyConnection.WriterStream; mirrorTerminal.Resized↔IPtyConnection.Resize(cols, rows). - Implement our own exit-code / kill / wait-for-exit against our
IPtyConnection— do NOT touchTerminalControl.ExitCode/Pid/Kill()/WaitForExit(), they NRE on the null_ptyConnection.
Note on environment: Porta.Pty.SpawnAsync already seeds the child from the
current (ClaudeDo) process environment (PATH/APPDATA/etc.) and merges
PtyOptions.Environment on top — so we only need to ADD our custom vars (MCP
config path, MAX_THINKING_TOKENS, …), not rebuild the whole environment. (The
earlier spike's "child gets zero env vars" claim was wrong.)
Command Center layout
MonitorPaneViewkeeps 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,InteractiveSessionServiceWorkerHubinteractive methods:OpenInteractiveTerminal,SendInteractiveMessage,RemoveQueuedInteractiveMessage,StopInteractiveSession,InterruptInteractiveSession- Broadcast events:
InteractiveSessionStarted/Ended,InteractiveQueueChanged,InteractiveMessageSent IdleSessionReaperandLiveSessionRegistryiff 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). IWorkerClientinteractive methods.
Open items (implementation time)
- Whether
LiveSessionRegistryis 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.