Neue Tabelle mission_control_sessions (Migration AddMissionControlSessions), befuellt vom InteractiveLaunchSpecService bei Task-, Ad-hoc- und Handoff-Kacheln. Flyout im Mission-Control-Header listet die letzten 14 Tage / 50 Sessions, Handoffs eingerueckt unter ihrer Ursprungs-Session, mit Fortsetzen (--resume im gespeicherten cwd) und Brief anzeigen.
399 lines
27 KiB
Markdown
399 lines
27 KiB
Markdown
# 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 `~/.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
|
||
|
||
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.
|
||
|
||
## 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`.
|
||
|
||
`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`.
|