13 KiB
ConPTY interactive sessions & launch specs
Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit
176ba78(2026-08-06). Drift check:git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axamlStable structure only (no line numbers). See docs/explore-notes/README.md.
Covers InteractiveLaunchSpecService and the four kinds of embedded ConPTY session the UI
process hosts (real claude TUI in a Mission Control tile).
Autonomous queue tasks are not covered here — they stay on the stream-json path via
TaskRunner. See worker-task-pipeline.md.
The four session kinds
| Kind | Hub spec method | Notes |
|---|---|---|
| Task session | GetInteractiveLaunchSpec |
Effort from the task/list model preset |
| Ad-hoc | GetAdHocLaunchSpec |
Effort from the global default |
| Planning | (planning start/resume) | Effort from PlanningAlias; uses --permission-mode default, not plan |
| List handler | GetMergeHelperLaunchSpec |
Effort from list config; --permission-mode auto (unattended) |
⚠️ Gotcha: never pass task free-text as a CLI argument
No ConPTY path ever passes task free-text (title / description / brief) as a CLI argument.
Every one of them writes it to a file first and hands claude a single-line kickoff pointing at
that file, exposed via --add-dir.
Two independent reasons:
- The ConPTY host flattens
Argsinto one command line to spawn the process, andclaudere-splits that line on whitespace. Any token starting with-in real task text (e.g.->,--abort) is then misread as an unknown option. - A raw multi-line positional prompt truncates at its first newline regardless.
A fresh task session's brief lives at ~/.todo-app/task-sessions/<taskId>/brief.md
(InteractiveLaunchSpecService.BuildFreshTaskArgsAsync). A task with neither title nor
description skips the file and the positional arg entirely.
Argument ordering
Every spec passes --effort <level> from the relevant model's preset. It leads the args —
except for a fresh task session with a brief, where --add-dir <sessionDir> must come first so
--effort (a single-value flag) can sit directly before the positional kickoff.
--model is deliberately NOT forced on an interactive session — the user can still switch
models in the TUI.
List handler ("Let Claude handle it")
BuildForMergeHelperAsync uses --permission-mode auto so it runs unattended. The
--allowedTools allowlist is the security boundary:
mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill.
MCP_TOOL_TIMEOUT is 200 s here — TaskWaitMcpTools clamps its own timeout to 170 s to stay
comfortably under it (see external-mcp.md).
The host task and its commit range
The handler run owns a real ClaudeDo task, created by CreateMergeHelperTask (hub) →
InteractiveLaunchSpecService.CreateMergeHelperTaskAsync, called by the UI before it opens
the tile:
- One new task per run in that list,
Idle+IsManual=true(never queued). - Title/description localized via
missionControl.mergeHelperTaskTitle/mergeHelperTaskDescriptionHeader. TaskEntity.HandlerBaseCommitstamped to the list repo's current HEAD.
The host task never gets a worktree of its own — the handler commits straight into the list's working dir and merges the tasks it handles itself. Consequences:
SubmitTaskForReviewbranches on whether the task has aWorktreeEntity: with one, it commits the worktree; without one, it stampsHandlerHeadCommitto the list repo's current HEAD. Both paths then flip the taskIdle/Failed→WaitingForReview.GetTaskDiffand the UI'sDetailsIslandViewModel/MergeSectionViewModelfall back to theHandlerBaseCommit..HandlerHeadCommitrange wheneverWorktreeis null.
UI flow
MergeHelperSelectionModalViewModel — checkbox picker over one list's non-terminal, non-manual
tasks, pre-ticking the actionable ones. List-scoped only (Configure(listId, listName), no
global scope). Opened from the list row's context menu, which is hidden when the list has no
working dir.
On confirm: ListsIslandViewModel raises LetClaudeHandleRequested → shell →
MissionControlViewModel.OpenMergeHelperConPtySessionAsync, which calls
CreateMergeHelperTaskAsync and then opens a task-based tile (deduped by TaskId like
OpenConPtySessionAsync, not CreateAdHoc) running the five-phase handler prompt.
Tile lifecycle
ConPtyPaneViewModel resolves its own launch spec — the ctor takes a descriptor factory,
the host wires handlers and then calls Start(). So the tile appears immediately with its
spinner while the worker is still preparing the worktree. A failed launch keeps the tile with an
inline error banner instead of the tile never appearing.
⚠️ Gotcha: the terminal library kills its child on visual-tree detach
Iciclecreek.Avalonia.Terminal's TerminalView.OnDetachedFromLogicalTree calls
CleanupProcess() (kills the PTY child) unless BeginReparent() suppressed it — and Mission
Control detaches pane views routinely (RebuildOverviewGrid recreates everything on any pane
add/remove/column change; focus-mode tab switches re-present content). Two-part defense (since
aac84e4):
PtyTerminalSession.StartAsyncputs the control in permanent reparent mode right afterLaunchProcess()—EndReparentis deliberately never called. Teardown is explicit only:ConPtyPaneViewModel.Dispose→Terminal.Kill()(pane close, VM disposal via DI on exit).ConPtyPaneHost(the DataTemplate content for a pane) reparents one long-livedConPtyPaneViewper pane VM (ConditionalWeakTable, view pins its ownDataContext) instead of letting the template instantiate a fresh view — a fresh view would render a dead, empty terminal because the running session is bound to the originalTerminalControl. Hosts only steal the view whileIsEffectivelyVisible; the layout toggle posts a reclaim pass (MissionControlView.ReclaimVisiblePaneHosts) so the now-visible layout re-steals.
Ellipse.spinner (IslandStyles) is the shared indeterminate spinner — used for a starting pane
(InteractiveTerminalViewModel.IsStarting) and in place of the refine button while
TaskRowViewModel.IsRefining.
⚠️ Gotcha: env-var launch race across sessions
PtyTerminalSession.StartAsync applies TerminalLaunchDescriptor.Env via
Environment.SetEnvironmentVariable onto the whole UI process (Porta.Pty has no per-launch
env seam — it always inherits the calling process's environment), then calls
TerminalControl.LaunchProcess(). Two sessions starting back-to-back (e.g. planning sessions for
two different tasks) could interleave: task B's SetEnvironmentVariable calls could land between
task A's env-set and its LaunchProcess() fork, so task A's claude process inherits B's env
(e.g. CLAUDEDO_PLANNING_TOKEN) and fails its own MCP auth. Fixed by serializing the
set-env-then-launch critical section behind a process-wide static SemaphoreSlim(1,1) in
PtyTerminalSession. Env leakage onto the whole process after a launch has forked remains a
documented limitation — only the fork-time race is closed.
⚠️ Gotcha: open-path dedupe races
MissionControlViewModel.OpenConPtySessionAsync / OpenPlanningConPtySessionAsync dedupe by
TaskId against ConPtySessions, but the check ran before an awaited DB title lookup and
only AddConPtyPane registers the pane — two rapid invocations for the same task (e.g. a
double-click) could both pass the dedupe check before either pane existed, opening two panes.
OpenMergeHelperConPtySessionAsync was worse: it awaits CreateMergeHelperTaskAsync (which mints
a brand-new task id every call) before any TaskId dedupe is even possible, so a double-trigger
always minted two host tasks in the DB.
Fixed with synchronous, pre-await claims: _pendingTaskOpens (shared by the two TaskId-keyed
open paths) and _pendingMergeHelperLists (keyed by listId, guarding the whole method since
there's no TaskId yet to dedupe on) are HashSet<string> fields checked-and-added at method
entry, before any await, and released in a finally. A second overlapping call for the same key
bails out immediately instead of racing past the collection-based dedupe.
Focus / key handling
InteractiveTerminalView lives in MissionControlWindow, so the FocusClearing Escape handler
(scoped to MainWindow via AddClassHandler<MainWindow>) never runs there — Escape always
reaches the PTY. See the note in src/ClaudeDo.Ui/CLAUDE.md.
TaskRowViewModel.HasInteractiveSession shows an accent "Interactive" chip instead of "Parked";
tapping it jumps to that Mission Control pane. TasksIslandViewModel.SyncInteractiveSessions
mirrors Mission Control's open panes onto the rows.
System-prompt matrix (autonomous vs. interactive)
Autonomous and interactive sessions do not share a system prompt. Per start path:
| Start path | Entry point | System prompt |
|---|---|---|
| Autonomous run/continue/retry | TaskRunner.ResolveConfigAsync → ClaudeArgsBuilder.Build |
--append-system-prompt <text>, recomputed and re-sent on every invocation including a --resume continue (PromptKind.System + improvement/list/task overrides) |
| Interactive task session, fresh | InteractiveLaunchSpecService.BuildForTaskAsync → BuildFreshTaskArgsAsync |
none — no --append-system-prompt(-file) at all |
| Interactive task session, resume | InteractiveLaunchSpecService.BuildForTaskAsync → WindowsTerminalLauncher.BuildResumeArgs |
none — only --resume <id> (+ --effort) |
| Ad-hoc directory session | InteractiveLaunchSpecService.BuildForDirectoryAsync |
none |
| Planning session start | InteractiveLaunchSpecService.BuildPlanningStart → WindowsTerminalLauncher.BuildPlanningStartArgs |
--append-system-prompt-file <path> (PromptKind.Planning) |
| Planning session resume | InteractiveLaunchSpecService.BuildPlanningResume → WindowsTerminalLauncher.BuildPlanningResumeArgs |
none — only --permission-mode default --allowedTools <planning allowlist> --resume <id> |
| List handler ("Let Claude handle it") | InteractiveLaunchSpecService.BuildForMergeHelperAsync |
--append-system-prompt-file <path> (PromptKind.MergeHelper), always fresh — this path never resumes |
So every interactive resume (task session and planning) drops the system prompt entirely — it's
not that they inherit the autonomous one, it's that no claude process on any resume path
ever passes --append-system-prompt(-file).
Does --resume bring back a prior --append-system-prompt? No.
Checked by reading real session transcripts (~/.claude/projects/<cwd>/<sessionId>.jsonl) for
several autonomous ClaudeDo task runs, including ones with multiple invocations (initial run +
ContinueAsync/retry on the same session id, confirmed via that project's task_runs history).
Grepped for the PromptKind.System default text ("You are completing one well-defined task
autonomously...") and for any "type":"system" entry or message.role == "system" anywhere in
those files: the prompt text only ever showed up as ordinary tool-result content (e.g. a task
that happened to read PromptFiles.cs's own source), never as a persisted system/config entry.
No session transcript — autonomous or interactive — carries a system-role message or a
per-session record of the CLI flags it was launched with; there is no sidecar file next to the
.jsonl either. The system prompt is purely a per-process request parameter the CLI builds fresh
from that invocation's own flags, never replayed from a resumed session's history. This matches
why TaskRunner.ContinueAsync (autonomous) explicitly re-resolves and re-passes
--append-system-prompt on every continue instead of relying on --resume to carry it —
if inheritance worked, that re-resolution would be redundant.
Conclusion: no leak. An interactive resume (task or planning) does not pick up the autonomous
run's --append-system-prompt text — including the "commit your work" / CLAUDEDO_BLOCKED
instructions from PromptKind.System. It simply runs with the claude CLI's own baseline system
prompt, same as every other path in this table that passes no system-prompt flag. No code change
needed here.
Related hub methods
GetInteractiveLaunchSpec, GetAdHocLaunchSpec, GetMergeHelperLaunchSpec,
CreateMergeHelperTask, SubmitTaskForReview.
Planning sessions: StartPlanningSession, ResumePlanningSession, DiscardPlanningSession,
FinalizePlanningSession, QueuePlanningSubtasks, GetPendingDraftCount,
GetPlanningAggregate, BuildPlanningIntegrationBranch.