Files
ClaudeDo/docs/explore-notes/conpty-sessions.md
T

13 KiB

ConPTY interactive sessions & launch specs

Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit 1a988ff (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.axaml Stable 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:

  1. The ConPTY host flattens Args into one command line to spawn the process, and claude re-splits that line on whitespace. Any token starting with - in real task text (e.g. ->, --abort) is then misread as an unknown option.
  2. 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.

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:

  1. Resume check. If the task isn't on a freshly (re)created worktree, BuildForTaskAsync picks a session to resume with task.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 behavior run?.SessionId alone used to provide).
  2. Fresh path. If neither is available (never run any way, or isFreshWorktree), a new Guid.NewGuid() is generated and persisted to TaskEntity.InteractiveSessionId via TaskRepository.SetInteractiveSessionIdAsyncbefore the LaunchSpec is returned, i.e. before the ConPTY host ever spawns claude. BuildFreshTaskArgsAsync then passes it as --session-id <guid>, placed as the single-value flag directly before the positional kickoff (or, with no brief, right after --effort).
  3. Fresh worktree wins. isFreshWorktree forces run to null and is checked before reading task.InteractiveSessionId, so a recreated worktree never resumes a stale id from either source — it always takes the fresh path, which overwrites the stale InteractiveSessionId with 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 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.HandlerBaseCommit stamped 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:

  • SubmitTaskForReview branches on whether the task has a WorktreeEntity: with one, it commits the worktree; without one, it stamps HandlerHeadCommit to the list repo's current HEAD. Both paths then flip the task Idle/FailedWaitingForReview.
  • GetTaskDiff and the UI's DetailsIslandViewModel / MergeSectionViewModel fall back to the HandlerBaseCommit..HandlerHeadCommit range whenever Worktree is 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):

  1. PtyTerminalSession.StartAsync puts the control in permanent reparent mode right after LaunchProcess()EndReparent is deliberately never called. Teardown is explicit only: ConPtyPaneViewModel.DisposeTerminal.Kill() (pane close, VM disposal via DI on exit).
  2. ConPtyPaneHost (the DataTemplate content for a pane) reparents one long-lived ConPtyPaneView per pane VM (ConditionalWeakTable, view pins its own DataContext) 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 original TerminalControl. Hosts only steal the view while IsEffectivelyVisible; 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.

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.ResolveConfigAsyncClaudeArgsBuilder.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.BuildForTaskAsyncBuildFreshTaskArgsAsync none — no --append-system-prompt(-file) at all
Interactive task session, resume InteractiveLaunchSpecService.BuildForTaskAsyncWindowsTerminalLauncher.BuildResumeArgs none — only --resume <id> (+ --effort)
Ad-hoc directory session InteractiveLaunchSpecService.BuildForDirectoryAsync none
Planning session start InteractiveLaunchSpecService.BuildPlanningStartWindowsTerminalLauncher.BuildPlanningStartArgs --append-system-prompt-file <path> (PromptKind.Planning)
Planning session resume InteractiveLaunchSpecService.BuildPlanningResumeWindowsTerminalLauncher.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.

GetInteractiveLaunchSpec, GetAdHocLaunchSpec, GetMergeHelperLaunchSpec, CreateMergeHelperTask, SubmitTaskForReview.

Planning sessions: StartPlanningSession, ResumePlanningSession, DiscardPlanningSession, FinalizePlanningSession, QueuePlanningSubtasks, GetPendingDraftCount, GetPlanningAggregate, BuildPlanningIntegrationBranch.