Files
ClaudeDo/docs/explore-notes/conpty-sessions.md
T
CubeGameLPandClaude Opus 5.5 f37b59d58f feat(mission-control): custom tiles — open any console program as a tile
Programs are configured in Settings → Custom tiles and stored in ui.config.json.
A tile with a fixed folder starts on click; without one it offers a submenu of
home, the list folders and browse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:09:19 +02:00

404 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 six session kinds
| Kind | Hub spec method | Notes |
|---|---|---|
| Task session | `GetInteractiveLaunchSpec` | No `--effort` — CLI default |
| Ad-hoc | `GetAdHocLaunchSpec` | No `--effort` — CLI default |
| Planning | (planning start/resume) | No `--effort`; 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) |
| SSH workspace | `GetSshLaunchSpec` | Exe is `ssh.exe`, not claude: `-R <random port>:127.0.0.1:<remote_mcp_port>` + one remote shell command that base64-decodes a `claudedo` MCP config (per-session token) to `~/.claudedo/mcp-<paneId>.json` and runs claude. **Not** recorded in `mission_control_sessions`, no `claudedo_pane`/handoff hint. Workspaces: `app_settings.ssh_workspaces`, Settings → SSH-Workspaces |
| Custom tile | none — built in the UI | `MissionControlViewModel.OpenCustomTileSession`: any console program from `ui.config.json` → `CustomTiles` (Settings → Custom tiles). Exe resolved via `ExecutableResolver` (shims through `cmd /c`), args split by `CustomTile.SplitArgs` (the library re-quotes each token). Empty folder → a submenu of home/list folders/browse. Not recorded, no env, no MCP |
## ⚠️ 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 `~/.claudeDo/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
Only the list-handler specs pass `--effort` (from the role model's preset). Hand-driven sessions
(task, ad-hoc, planning, handoff, resume) deliberately pass none, so the CLI default / the user's
`/effort` setting applies. A positional kickoff must follow a single-value flag (`--session-id`,
`--append-system-prompt-file`), never a variadic one (`--add-dir`, `--allowedTools`).
`--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, as the only arg).
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.
## Session handoff (`handoff_session`)
Task and ad-hoc tiles can hand off to a fresh session in the same tile. The **paneId is the pane's
`IdentityKey`**: the task id for a task tile, a GUID minted by `OpenAdHocConPtySessionAsync` for an
ad-hoc one.
**The model never passes an id** (it guesses GUIDs). Instead, every such spec gets
`--mcp-config ~/.claudeDo/pane-sessions/<paneId>/mcp.json`, which adds a second entry,
`claudedo_pane`. It points at the same external MCP, with headers `X-ClaudeDo-Pane` and
`X-ClaudeDo-Session` (plus `X-ClaudeDo-Key` when one is configured). An `AddListToolsFilter`
(`SessionHandoffMcpTools.FilterByPane`) splits the tool list by that header:
- `claudedo_pane` lists only `handoff_session`;
- the plain `claudedo` entry lists everything except `handoff_session`.
The hint (`handoff-hint.md`, via `--append-system-prompt-file`) says when to call
`mcp__claudedo_pane__handoff_session(brief)` and what goes into the brief, and contains no ids. Ad-hoc
tiles get a pre-assigned `--session-id` so the header has one. With `ExternalMcpPort = 0` the tile
gets neither the entry nor the hint.
`handoff_session(brief)` writes `pane-sessions/<paneId>/handoffs/<guid>/brief.md` (first line:
`Handed off from session <id>`) and broadcasts `SessionHandoffRequested(paneId, briefPath)`. The
worker has no pane registry: the UI looks up the pane by `IdentityKey`, takes its `Cwd` (from the
resolved launch spec) and its `TaskId`, and closes it. It then reopens the tile at the same slot
with the same `IdentityKey` via `GetSessionHandoffLaunchSpec` → `BuildHandoffAsync`. For a task
tile, the new session id replaces `InteractiveSessionId`, so reopening the task resumes the
handed-off session. The old transcript remains under `~/.claude/projects/`.
## Recent sessions
Every task, ad-hoc and handoff tile launch (only those — `paneId != null`) records its session in
`mission_control_sessions` from inside `InteractiveLaunchSpecService`, which is where the session id
is known. A handoff's predecessor comes from the brief's first line
(`SessionHandoffMcpTools.ReadHandedOffFrom`), and it inherits the predecessor's title and task.
The clock button in the Mission Control header opens a flyout (`RefreshRecentSessionsAsync`, last 14
days / 50 rows, read straight from the DB). `NestHandoffs` groups each chain under its origin.
- **Resume**: a task's *current* `InteractiveSessionId` reopens the task tile (deduped by TaskId).
Any other session gets a fresh ad-hoc tile via `GetResumeSessionLaunchSpec` → `BuildResumeAsync`
(`--resume <id>` in the recorded cwd, handoff hint included).
- **Brief**: `ShellOpen.Path(brief.md)`, which opens the file in the default editor.
- Not guarded: resuming a session that is still live in another ad-hoc tile, since panes don't
track their session id.
## 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.
The chain length is bounded server-side by `HandoffRoundTracker`, whose cap comes from
`AppSettingsEntity.ListHandlerMaxHandoffs` (default 4 = two merge rounds; `HandoffMcpTools` reads it
per call and `Register` clamps to min 1, so 0 can never mean "unbounded"). Past the cap a
`wait`/`merge` request is silently coerced to its `_final` counterpart — check the result's
`coerced` flag — and any handoff after a `merge_final` round throws.
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (Settings → Execution) **and** the
list's own `ListConfigEntity.PowerUseAllowed` are both set, `InteractiveLaunchSpecService.
ComposeHandlerPromptAsync` appends `PromptKind.PowerUseAddendum` to whichever handler prompt the
phase resolved to. The addendum is written as an *override* of the base prompt's interactive rules
(never ask, decide and record instead; resolve or abort conflicts rather than leaving them in the
tree; harvest new tasks when a round would starve). Both keys are required on purpose: the global
switch can stay on overnight without turning an unrelated list's handler run unattended. A run that
is not in PowerUse gets a byte-identical prompt to the pre-PowerUse behaviour — there is deliberately
no second set of handler prompts to keep in sync.
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)).
### Auto-restart after the usage limit
A handler session that dies mid-phase used to be a silent dead tile: `InteractiveTerminalViewModel.
OnSessionProcessExited` only raised a visible banner for a death **inside** its 5 s
`StartupGraceWindow`. It now raises `ExitedAfterStartup` for a later non-zero exit; the pane
forwards it as `SessionDiedAfterStartup(taskId, exitCode)` and `MissionControlViewModel` reports it
via `WorkerHub.ReportInteractiveSessionExited`. Every task-based pane reports — the UI does no
filtering, because the exit code is the *only* thing it knows.
`HandlerRestartCoordinator` (Worker) decides the rest and **fails closed** — unlike the usage gate,
every missing signal means "don't relaunch":
- **Which round was running** comes from `HandlerRoundState`, a JSON file at
`~/.claudeDo/handler-rounds.json` (one `HandlerRound` per handler task id, deliberately not a DB
column). `BuildForMergeHelperAsync` writes the opening round as phase `"triage"`;
`BuildForMergeHelperHandoffAsync` writes each subsequent `MergeHelperPhase`. Without it the phase
and task set exist *only* in the transient `HandoffRequested` broadcast, so a dead tile could not
be reopened. A `"triage"` round is never reopened — there is no handoff spec for it.
- **Was it really the usage limit?** A ConPTY session has no captured stdout, so `TaskRunner.
IsUsageLimitMessage` (which matches the CLI's own "session limit" text) has nothing to read. The
corroborating witness is `UsageState.Snapshot.FiveHour.Utilization ≥ 95`
(`HandlerRestartCoordinator.UsageLimitProofPct`) at the moment of death. A crash at 40 % is not
restarted; no snapshot at all is not restarted.
- **PowerUse only** — both keys, exactly like the prompt addendum above.
- **One restart per round**, claimed via `HandlerRoundState.TryClaimAutoRestart` before the wait, so
a restart that dies again stays dead. `HandlerRoundState.Save` carries `AutoRestartUsed` over when
the phase is unchanged, because the restart's own launch-spec build writes the round again — a
naive overwrite would hand out a fresh budget every time and loop all night.
- The restart waits out `FiveHour.ResetsAt`, then re-broadcasts `HandoffRequested` with the
persisted phase + task set, which the UI already handles by reopening the tile. It goes **around**
`handoff_list_handler`, so `HandoffRoundTracker.Register` is never called and the run loses no
handoff budget.
Sibling to `UsageLimitAutoContinueCoordinator`, not an extension of it: that one only ever sees
headless runs (`FailureReason == "usage_limit"`, resumed through the override slot), and a ConPTY
session has no `task_runs` row to classify.
### 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`.
**AltGr bypass:** Windows reports AltGr as Ctrl+Alt, and Iciclecreek's `TerminalView.OnKeyDown` turns any Ctrl/Alt combo into a control sequence (NUL/0x1C/ESC) and marks it handled, so `@ \ { } € ~ |` never arrive as text. `InteractiveTerminalView.OnPreviewKeyDown` (tunnel) detects it via `IsAltGrText` and sends `KeySymbol` through `SendTextAsync` first.
`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>` |
| 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`.