# 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.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` / `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:

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.

### ⚠️ 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:

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 <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`/`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](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<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`.
