Each handoff tile is a live claude process with the full mcp__claudedo__* surface; leaving the outgoing phase's pane open leaked one process per phase. Close it via CloseConPtySession before opening the next phase's tile, restoring the one-pane-per-TaskId invariant so OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can go back to FirstOrDefault.
20 KiB
ConPTY interactive sessions & launch specs
Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit
79b3580(2026-08-11). 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 / GetMergeHelperHandoffLaunchSpec |
Model + effort fixed per role (ModelRegistry.HandlerTriageAlias/HandlerWaitAlias/HandlerMergeAlias), not from list config; --permission-mode via PermissionModeResolver (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 — it still gets --session-id.
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.
⚠️ Gotcha: no directory arg may end in a separator
PtyTerminalSession hands Args to TerminalControl.Args, which the library flattens into ONE
Windows command line with each token quoted. Windows argv rules read \" as an escaped quote,
so a token like "C:\repo\" never closes and every following argument is swallowed by the
preceding variadic flag. A list working dir stored as C:\Dev\Repos\Bandel.Hub\ therefore
fed --add-dir the repo, --append-system-prompt-file, its value and the positional kickoff:
the CLI warned brief.md is not a directory and the session opened with no prompt at all
(2026-08-06). BuildForMergeHelperAsync/BuildForMergeHelperHandoffAsync run the repo through
TrimTrailingSeparator; session dirs the worker builds never carry one.
The same data bit the UI's "Open in terminal" on 2026-08-10 (wt -d "C:\…\StaplerTracking\" →
Could not access starting directory "C:\…\StaplerTracking""), so the fix moved to the write side:
ListRepository.AddAsync/UpdateAsync normalize WorkingDir via Paths.TrimTrailingSeparator,
which covers every writer (UI create, repo import, hub UpdateList, MCP CreateList/UpdateList) —
the UI and worker keep their own defensive trim for rows written before that. Anything new that puts
a user-supplied path on a command line should use Paths.TrimTrailingSeparator and, on the
ProcessStartInfo side, ArgumentList rather than an interpolated Arguments string.
Diagnosing this from code or a PowerShell repro is a dead end — PowerShell quotes correctly, so
every repro passes. Read the real command line instead:
Get-CimInstance Win32_Process -Filter "Name = 'claude.exe'".
Resuming a task session (TaskEntity.InteractiveSessionId)
claude --session-id <uuid> lets the caller pre-assign a conversation's session id instead of
waiting for the CLI to generate one. BuildForTaskAsync uses this so a closed or aborted
interactive task session can be resumed even if it never got far enough to write anything to its
own transcript:
- Resume check. If the task isn't on a freshly (re)created worktree,
BuildForTaskAsyncpicks a session to resume withtask.InteractiveSessionId ?? run?.SessionId— this task's own last interactive conversation takes precedence over the latest autonomous run's session, since they're distinct conversations even against the same worktree. A task that has only ever run autonomously still resumes into that run's session the first time it's opened interactively (this is the pre-existing behaviorrun?.SessionIdalone used to provide). - Fresh path. If neither is available (never run any way, or
isFreshWorktree), a newGuid.NewGuid()is generated and persisted toTaskEntity.InteractiveSessionIdviaTaskRepository.SetInteractiveSessionIdAsync— before theLaunchSpecis returned, i.e. before the ConPTY host ever spawnsclaude.BuildFreshTaskArgsAsyncthen passes it as--session-id <guid>, placed as the single-value flag directly before the positional kickoff (or, with no brief, right after--effort). - Fresh worktree wins.
isFreshWorktreeforcesruntonulland is checked before readingtask.InteractiveSessionId, so a recreated worktree never resumes a stale id from either source — it always takes the fresh path, which overwrites the staleInteractiveSessionIdwith the new one.
Net effect: reopening an interactive session for a task (pane closed, process killed, whatever) resumes the same claude conversation, because the id was committed to the DB before the previous launch even started.
List handler ("Let Claude handle it")
BuildForMergeHelperAsync/BuildForMergeHelperHandoffAsync resolve --permission-mode via
PermissionModeResolver (currently always auto, since none of the handler roles run on haiku)
so the session runs unattended. The --allowedTools allowlist is the security boundary:
mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task (Task lets the Merge
role delegate diff reviews to sonnet subagents).
The run now spans up to five phase-scoped sessions instead of two, chained via
handoff_list_handler(taskId, survivingTaskIds, nextPhase): opus Triage → sonnet Wait → opus
Merge → (only if Merge started reruns) sonnet Wait(final) → opus Merge(final). nextPhase (wait
| merge | wait_final | merge_final, validated by MergeHelperPhase.Validate) picks both the
system prompt (PromptKind.MergeHelperWait/MergeHelperMerge) and the model
(ModelRegistry.HandlerWaitAlias/HandlerMergeAlias) for the next session; the _final variants
share the same prompt/model as their non-final counterpart and differ only in one extra line
rendered into the handoff kickoff file (PromptKind.MergeHelperHandoff) marking the final round
and forbidding further reruns.
Each handoff hands the SAME handler task id to a NEW tile, and MissionControlViewModel. OpenMergeHelperHandoffConPtySessionAsync closes the outgoing phase's pane (CloseConPtySession,
which disposes its PtyTerminalSession and kills the underlying claude process) before opening
the next one — every tile is a live process with the full mcp__claudedo__* surface, and the
outgoing session is only ever told to end its turn, never to exit. This keeps the one-pane-per-
TaskId invariant that ConPtySessions relies on elsewhere (OpenConPtySessionAsync's dedupe,
OnPaneSubmitForReview) — both use a plain FirstOrDefault(s => s.TaskId == taskId), not a
"newest wins" lookup.
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.
SubmitForReviewCommand.CanExecute also gates on Terminal.IsStarting / StartError /
HasExited (not just IsTaskBased) — a starting or dead pane can't offer a review it would only
have the worker reject, and MissionControlViewModel.OnPaneSubmitForReview sets the pane's
IsSubmitPending flag for the duration of the round trip so a rapid double-click can't race two
SubmitTaskForReviewAsync calls. A failed launch also offers RetryCommand (visible whenever
HasExited && StartError != null) — it swaps in a fresh InteractiveTerminalViewModel and calls
Start() again on the same pane/TaskId dedupe slot, since PtyTerminalSession throws on a
second StartAsync call and can't be restarted in place.
⚠️ 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.
Queueing is gated on an open session (UI-only, since d84607f)
A task-based session leaves the row Idle (sessions never write Status), so nothing on the
worker side distinguishes it from a plain idle task. TaskRowViewModel.CanSendToQueue and
MissionControlViewModel.EnqueueTaskAsync (drag-to-queue onto the Command Center window) both
check for an open session before queueing — the row via HasInteractiveSession, the drag path via
ConPtySessions.Any(s => s.TaskId == taskId) (Mission Control's own authoritative pane list,
since the mirrored bool on the row could lag). Queueing a task open in a hand-driven ConPTY pane
would otherwise let the picker spawn an autonomous claude process into the same worktree the
user is editing. Both enqueue paths (TasksIslandViewModel.SendToQueueAsync and
MissionControlViewModel.EnqueueTaskAsync) also route through IWorkerClient.SetTaskStatusAsync
(hub SetTaskStatus → TaskStateService.EnqueueAsync) instead of a raw EF write, so the
manual/draft-child guards apply on both paths too.
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"), triage | InteractiveLaunchSpecService.BuildForMergeHelperAsync |
--append-system-prompt-file <path> (PromptKind.MergeHelperTriage — phases 0–2 only), always fresh — this path never resumes |
| List handler, post-handoff (wait/wait_final) | InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync |
--append-system-prompt-file <path> (PromptKind.MergeHelperWait — phase 3 only), fresh session dir, same handler task id |
| List handler, post-handoff (merge/merge_final) | InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync |
--append-system-prompt-file <path> (PromptKind.MergeHelperMerge — phases 4–5 only), fresh session dir, same handler task id |
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.