Threads a nextPhase parameter (wait/merge/wait_final/merge_final, validated by the new MergeHelperPhase) from handoff_list_handler through HubBroadcaster/WorkerHub into InteractiveLaunchSpecService, which now picks the next session's system prompt (MergeHelperWait/MergeHelperMerge) and model (HandlerWaitAlias/HandlerMergeAlias) from it instead of hardcoding the old two-phase Execute prompt -- this also fixes a build break left by the prior prompt-split task, which removed PromptKind.MergeHelperExecute without updating its only caller. Also sets --model/--effort/--permission-mode explicitly for every list-handler session (Triage included) via PermissionModeResolver instead of inheriting the CLI's ambient model and hardcoding "auto", and adds Task to the merge-helper allowlist so the Merge phase can delegate diff reviews to subagents.
295 lines
20 KiB
Markdown
295 lines
20 KiB
Markdown
# 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` / `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.
|
||
|
||
`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`.
|