# ConPTY interactive sessions & launch specs > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. > Last verified against commit `8dbdfb3` (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](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//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 ` from the relevant model's preset. It **leads** the args — except for a fresh task session with a brief, where `--add-dir ` 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 ` 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.SetInteractiveSessionIdAsync` — **before** the `LaunchSpec` is returned, i.e. before the ConPTY host ever spawns `claude`. `BuildFreshTaskArgsAsync` then passes it as `--session-id `, 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](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`/`Failed` → `WaitingForReview`. - `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. `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`): 1. `PtyTerminalSession.StartAsync` puts the control in **permanent reparent mode** right after `LaunchProcess()` — `EndReparent` is deliberately never called. Teardown is explicit only: `ConPtyPaneViewModel.Dispose` → `Terminal.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`. ### ⚠️ 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` 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`) 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 `, 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 ` (+ `--effort`) | | Ad-hoc directory session | `InteractiveLaunchSpecService.BuildForDirectoryAsync` | none | | Planning session start | `InteractiveLaunchSpecService.BuildPlanningStart` → `WindowsTerminalLauncher.BuildPlanningStartArgs` | `--append-system-prompt-file ` (`PromptKind.Planning`) | | Planning session resume | `InteractiveLaunchSpecService.BuildPlanningResume` → `WindowsTerminalLauncher.BuildPlanningResumeArgs` | none — only `--permission-mode default --allowedTools --resume ` | | List handler ("Let Claude handle it"), triage | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file ` (`PromptKind.MergeHelperTriage` — phases 0–2 only), always fresh — this path never resumes | | List handler, post-handoff | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file ` (`PromptKind.MergeHelperExecute` — phases 3–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//.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`.