249 lines
16 KiB
Markdown
249 lines
16 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` | Effort from list config; `--permission-mode auto` (unattended) |
|
|
|
|
## ⚠️ Gotcha: never pass task free-text as a CLI argument
|
|
|
|
**No ConPTY path ever passes task free-text (title / description / brief) as a CLI argument.**
|
|
Every one of them writes it to a file first and hands `claude` a single-line kickoff pointing at
|
|
that file, exposed via `--add-dir`.
|
|
|
|
Two independent reasons:
|
|
|
|
1. The ConPTY host flattens `Args` into **one command line** to spawn the process, and `claude`
|
|
re-splits that line on whitespace. Any token starting with `-` in real task text (e.g. `->`,
|
|
`--abort`) is then misread as an unknown option.
|
|
2. A raw multi-line positional prompt truncates at its **first newline** regardless.
|
|
|
|
A fresh task session's brief lives at `~/.todo-app/task-sessions/<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.
|
|
|
|
## 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` uses `--permission-mode auto` so it runs unattended. The
|
|
`--allowedTools` allowlist is the security boundary:
|
|
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`.
|
|
|
|
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
|
|
comfortably under it (see [external-mcp.md](external-mcp.md)).
|
|
|
|
### The host task and its commit range
|
|
|
|
The handler run **owns a real ClaudeDo task**, created by `CreateMergeHelperTask` (hub) →
|
|
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`, called by the UI *before* it opens
|
|
the tile:
|
|
|
|
- One new task per run in that list, `Idle` + `IsManual=true` (never queued).
|
|
- Title/description localized via `missionControl.mergeHelperTaskTitle` /
|
|
`mergeHelperTaskDescriptionHeader`.
|
|
- `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD.
|
|
|
|
The host task **never gets a worktree of its own** — the handler commits straight into the
|
|
list's working dir and merges the tasks it handles itself. Consequences:
|
|
|
|
- `SubmitTaskForReview` branches on whether the task has a `WorktreeEntity`: with one, it
|
|
commits the worktree; without one, it stamps `HandlerHeadCommit` to the list repo's current
|
|
HEAD. Both paths then flip the task `Idle`/`Failed` → `WaitingForReview`.
|
|
- `GetTaskDiff` and the UI's `DetailsIslandViewModel` / `MergeSectionViewModel` fall back to the
|
|
`HandlerBaseCommit`..`HandlerHeadCommit` range whenever `Worktree` is null.
|
|
|
|
### UI flow
|
|
|
|
`MergeHelperSelectionModalViewModel` — checkbox picker over one list's non-terminal, non-manual
|
|
tasks, pre-ticking the actionable ones. **List-scoped only** (`Configure(listId, listName)`, no
|
|
global scope). Opened from the list row's context menu, which is hidden when the list has no
|
|
working dir.
|
|
|
|
On confirm: `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell →
|
|
`MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which calls
|
|
`CreateMergeHelperTaskAsync` and then opens a **task-based** tile (deduped by `TaskId` like
|
|
`OpenConPtySessionAsync`, **not** `CreateAdHoc`) running the five-phase handler prompt.
|
|
|
|
## Tile lifecycle
|
|
|
|
`ConPtyPaneViewModel` resolves its own launch spec — the ctor takes a descriptor **factory**,
|
|
the host wires handlers and then calls `Start()`. So the tile appears **immediately** with its
|
|
spinner while the worker is still preparing the worktree. A failed launch keeps the tile with an
|
|
inline error banner instead of the tile never appearing.
|
|
|
|
### ⚠️ 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") | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelper`), always fresh — this path never resumes |
|
|
|
|
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`.
|