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

6.1 KiB

ConPTY interactive sessions & launch specs

Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit f6cb825 (2026-08-05). Drift check: git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub 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.

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.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.

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.

GetInteractiveLaunchSpec, GetAdHocLaunchSpec, GetMergeHelperLaunchSpec, CreateMergeHelperTask, SubmitTaskForReview.

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