Compare commits

..
163 Commits
Author SHA1 Message Date
mika kuns bf19e84e76 Merge remote-tracking branch 'origin/main'
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 42s
2026-08-06 14:55:17 +02:00
mika kuns 32ef88526e test(worker): poll for terminal state in the registration-race tests instead of fixed delays 2026-08-06 14:53:12 +02:00
mika kuns 30efbcbf2f Merge branch 'claudedo/a935c68eed6341bfb96f0bb82f13354e' 2026-08-06 14:43:57 +02:00
mika kuns 96723f4582 Merge claudedo/28d494e791e842fc966eb8691181e6da 2026-08-06 14:41:37 +02:00
mika kuns 967359d6e7 Merge claudedo/ab96ad81d417402088aa669ed52259e9 2026-08-06 14:41:08 +02:00
mika kuns ab888e5291 Merge claudedo/43bb79c93e694f7cbec8ae41a05004bc 2026-08-06 14:40:24 +02:00
mika kuns 7337312eca Merge claudedo/72b309e176cb4a96945159fd8dd6ce92 2026-08-06 14:39:34 +02:00
mika kuns c07225f530 Merge claudedo/58764ab72f3e41e0871b9c81149ba1cc 2026-08-06 14:39:06 +02:00
mika kuns ff52fb9eb1 Merge claudedo/e1933d2e09014050812c1fdab2240e13 2026-08-06 14:38:29 +02:00
mika kuns 109e85da83 fix(worker): honor RunCancellationRegistry.Register's return value at both dispatch sites
Register(taskId, cts) already refuses (and logs) a double-registration, but
both call sites discarded the bool and dispatched anyway under an
unregistered CTS. If the loser then unregistered the winner's CTS during
its own cleanup, TryCancel could silently no-op against a live process.

- OverrideSlotService.StartInSlot now fails RunNow/ContinueTask loudly
  (throws) when it loses the registration race instead of registering
  over — or silently proceeding despite losing to — the queue picker.
- QueueService's picker loop retries registration briefly (the loser's own
  claim-attempt-then-unregister resolves fast) before dispatching; if
  registration never resolves it marks the already-claimed row Failed
  instead of running it unregistered.
- RunCancellationRegistry.Unregister already had compare-and-remove
  semantics (TryRemove(KeyValuePair)), so a loser's cleanup could not have
  removed the winner's CTS once registration correctly failed.

Added regression tests exercising the real registry through both dispatch
paths: RunNow losing the registration race throws without disturbing the
winner, the picker's retry succeeds and TryCancel reaches the live run when
the loser unregisters in time, and the picker fails the task instead of
running unregistered when it never does.
2026-08-06 14:34:48 +02:00
mika kuns 4a28bfe82e fix(ui): treat an immediate ConPTY exit as a start failure
InteractiveSessionId is persisted before the ConPTY process spawns (1a988ff).
If `claude --session-id <guid>` exits immediately after launch (auth/network
hiccup, crash before the TUI starts), OnSessionProcessExited set HasExited
but never StartError, so ConPtyPaneViewModel.CanRetry (which requires
StartError) never offered Retry — every reopen just resumed the same dead
session id, permanently poisoning that task's interactive sessions.

Now a nonzero exit within 5s of the session becoming "running" is treated
as a died-at-startup failure and routed through the same StartError path
as a launch-time exception, so the pane shows the error banner + Retry.

Retry re-fetches the LaunchSpec via BuildForTaskAsync, which reuses the
same persisted InteractiveSessionId — correct for a transient failure
(fresh process, same id), but does not help a genuinely dead session id.
Clearing a dead session id server-side is a separate design question,
left out of scope here.
2026-08-06 14:33:24 +02:00
mika kuns 27d50b81ff fix(ui): surface merge-drain cancel rejection in task-row quick cancel
TasksIslandViewModel.CancelReviewAsync swallowed the HubException the
worker raises when a task's unit merge is draining (79f90a9), unlike
the details-pane version fixed in the same commit. Mirror that fix:
report the rejection via ErrorReported instead of a bare catch.
2026-08-06 14:31:13 +02:00
mika kuns 166021049a fix(ui): close interactive-session gate bypasses in reset-and-retry and plan queueing
Reset & Retry discarded the branch and queued an autonomous run even while the
user had an interactive ConPTY pane open on the task, and finalizing a plan
queued every child unconditionally (the hub has no notion of a UI-hosted
session) — both bypassed the HasInteractiveSession gate added for
CanSendToQueue. CanResetAndRetry now checks it too, with a subscription on the
bound task so the command re-evaluates when the flag flips without Task
itself changing; SendToQueueAsync now blocks queuing the whole plan and
surfaces the affected child titles when any child has an open session.
2026-08-06 14:30:24 +02:00
mika kuns a78cc526a6 fix(ui): surface worker-offline failures when deleting a task
DeleteTaskAsync had no IsConnected guard, unlike every other worker-dependent
command in DetailsIslandViewModel, so an offline delete was a silent no-op.
WorkerClient.DeleteTaskAsync also only caught HubException, letting the
InvalidOperationException thrown by an inactive hub connection escape into
the unobserved command task and vanish.

Gate DeleteTaskCommand behind CanDeleteTask (Task != null && IsConnected),
re-evaluate it on connection-state changes, widen WorkerClient to catch the
connection-inactive case too, and wrap the ViewModel's call in try/catch as
a second line of defense against a race between the guard and the call.
2026-08-06 14:29:15 +02:00
mika kuns 4310f88ebf fix(ui): bound the ConPTY launch-gate wait so a hung launch can't freeze other panes
StartAsync awaited the static launch-gate semaphore with no timeout, so one
hung launch (slow disk, AV scanning claude.exe, a Porta.Pty/ConPTY hiccup)
blocked every subsequent pane open behind it indefinitely, including Retry.
Extracted the wait into WaitForLaunchGateAsync with a 30s timeout that
throws TimeoutException before the try/finally (never releasing a gate it
didn't acquire); the exception flows through StartCoreAsync's existing
catch into StartError, so the pane shows an error + Retry instead of
hanging. StartError renders ex.Message directly (no locale key involved),
so no locale changes were needed.

PtyTerminalSession.StartAsync itself needs a real TerminalControl and stays
impractical to unit-test directly, so tests target the extracted
WaitForLaunchGateAsync helper against a plain SemaphoreSlim: timeout throws,
timeout never acquires the gate, and success still acquires it.
2026-08-06 14:29:05 +02:00
mika kuns b97f55bfb6 fix(ui): reparse-check repo-scan root folder and move scan off UI thread
RepoScanner.Scan() only guarded reparse points on subdirectories found
during recursion, so a configured import root that is itself a junction
walked straight through onto other drives. Apply the same
FileAttributes.ReparsePoint check to the root before scanning.

RepoImportModalViewModel.ScanAndAdd ran the full 5-level recursive scan
synchronously on the UI thread; pointing the folder picker at a broad
directory froze the app. Offload RepoScanner.Scan to Task.Run per folder
and apply results back on the UI thread.

Add a RepoScannerTests case that creates a real junction (mklink /J) at
the scan root and asserts Scan returns empty.
2026-08-06 14:27:39 +02:00
mika kuns bac8387069 Merge claudedo/c52ba287db6e4a7c9bac38c30bcc21ec 2026-08-06 13:50:41 +02:00
mika kuns d1ed29f19f Merge branch 'claudedo/12bae32376504fd28bedb3c7202feba5' 2026-08-06 13:50:14 +02:00
mika kuns 8dbdfb3b89 Merge branch 'claudedo/a92b87e7748840ababcfc87423a8636c' 2026-08-06 13:49:30 +02:00
mika kuns ddf68d66cc Merge claudedo/1f4f59b14e4d481d97e843b6b2014af5 2026-08-06 13:48:37 +02:00
mika kuns bd83cac57d Merge claudedo/973ea49ec0a642ea9b1ddcff9b71b6fe 2026-08-06 13:47:35 +02:00
mika kuns b2940bf1ff Merge claudedo/c4930a56575e45b2ae9be47b1c49eb39 2026-08-06 13:47:11 +02:00
mika kuns 7f03f04268 Merge branch 'claudedo/9c0bff67147148f3beeee180e70b5503' 2026-08-06 13:46:52 +02:00
mika kuns cc90600f72 Merge claudedo/81b054800dc4446598685fd04fefddb5 2026-08-06 13:45:15 +02:00
mika kuns 003e7b2b78 Merge claudedo/15357b9c652545eda0a1226042d7ebc9 2026-08-06 13:44:58 +02:00
mika kuns c90f93e57f Merge claudedo/f5500cade3a34e42876dbbafc8161831 2026-08-06 13:44:33 +02:00
mika kuns 4d9ceefee2 docs(explore-notes): document the ConPTY env race and open-path dedupe fixes 2026-08-06 13:43:12 +02:00
mika kuns 176ba78e11 fix(ui): serialize ConPTY env launch and close open-path dedupe races
Two sessions starting back-to-back could interleave SetEnvironmentVariable
calls before either LaunchProcess() forks, leaking one task's env (e.g.
CLAUDEDO_PLANNING_TOKEN) into another's claude process. Serialize the
set-env + LaunchProcess critical section behind a static SemaphoreSlim in
PtyTerminalSession.

OpenConPtySessionAsync/OpenPlanningConPtySessionAsync ran their TaskId
dedupe check before an awaited DB title lookup, and OpenMergeHelperConPtySessionAsync
awaited task creation before any dedupe was possible - rapid double-invocation
could open two panes or mint two host tasks. Claim the key synchronously at
method entry, before any await, and release it in a finally.
2026-08-06 13:43:00 +02:00
mika kuns 57c61a2043 fix(ui): gate Mission Control submit-for-review, add retry, fix event leak
Submit for Review is now disabled while a ConPTY pane is starting, has
failed to launch, or has already exited, and MissionControlViewModel
guards against a rapid double-click racing two SubmitTaskForReviewAsync
calls. A failed launch no longer permanently occupies its TaskId dedupe
slot -- a Retry button re-fetches the launch spec and restarts the pane
in place. CloseConPtySession/Dispose now also unsubscribe
SubmitForReviewRequested, matching the other pane event handlers. Also
fixes the pre-existing nullable-dereference warning in
IslandsShellViewModel.SyncInteractiveSessionChips.
2026-08-06 13:42:06 +02:00
mika kuns 79f90a9a8e fix(worker,ui): block cancelling a task while its unit merge is draining
TaskStateService.CancelAsync allowed cancelling a WaitingForReview task
even while PlanningMergeOrchestrator was mid-drain on it: ApproveReview
awaits the whole multi-subtask merge synchronously, so a concurrent
CancelReview (UI or MCP) could flip the parent to Cancelled while the
orchestrator kept merging children onto the target branch, then
FinalizeParentDoneAsync would find the parent no longer WaitingForReview
and give up - leaving the merged diffs stranded with no rollback.

CancelAsync now rejects with a clear reason when HasActiveMerge(taskId)
is true. TaskStateService can't take PlanningMergeOrchestrator as a
direct constructor dependency (circular back to ITaskStateService), so
it takes a lazily-resolved Func<IActiveMergeState> instead, mirroring
the existing Func<ITaskStateService> cycle-break already used for
PlanningChainCoordinator.

UI polish: DetailsIslandViewModel.IsMergeDraining gates
CancelReviewCommand's CanExecute (same shape as
WorktreesOverviewModalViewModel.IsMerging), and the command's catch now
raises ErrorReported instead of swallowing the rejection silently.
2026-08-06 13:38:02 +02:00
mika kuns efee14780b fix(worker): filter the transliterated 'fuer' stopword, not the untransliterated 'fur'
NormalizeTitleWords transliterates umlauts before tokenizing ('für' ->
'fuer'), but TitleStopWords listed 'fur' -- a token the pipeline can
never produce -- so 'für' was never filtered as filler and could push
unrelated titles past the near-duplicate threshold.
2026-08-06 13:37:35 +02:00
mika kuns 42a234b46e docs(explore-notes): reflect claim-before-create ordering in worker-task-pipeline
Bumps the verified-against commit to 774f9d3 (the RunNow/picker
double-dispatch race fix) and documents the new claim-before-create
ordering and the RunCancellationRegistry double-registration guard.
2026-08-06 13:33:35 +02:00
mika kuns 774f9d3d13 fix(worker): claim Running before creating run resources
TaskRunner.RunAsync created the worktree (PrepareRunDirectoryAsync)
before claiming Running via StartRunningAsync. RunNow dispatches by
task id with no atomic claim of their own, so a Queued task racing
the queue picker's atomic SQL claim could hit WorktreeManager's
branch-collision self-heal, which force-removes and recreates the
winner's live worktree mid-run.

Move the claim before any resource creation and bail out immediately
when it's rejected. OverrideSlotService.RunNow also fast-rejects a
task already Running in the DB (defense in depth). RunCancellationRegistry
now refuses (and logs) a double registration instead of silently
overwriting the first CTS, so a losing dispatch's cleanup can no
longer unregister the winner's cancellation token.
2026-08-06 13:33:23 +02:00
mika kuns ed7cd7dcf6 docs(explore-notes): document the queueing gate on open ConPTY sessions
Bump conpty-sessions.md's verified-against commit to d84607f and add a
section covering the CanSendToQueue / EnqueueTaskAsync interactive-session
gate and the switch to routing through the worker hub.
2026-08-06 13:30:06 +02:00
mika kuns d84607f796 fix(ui): gate queueing on an open interactive ConPTY session
A task-based ConPTY session leaves the row Idle in the DB (sessions never
touch status), so nothing stopped the queue picker from claiming it too:
CanSendToQueue ignored HasInteractiveSession, and both TasksIslandViewModel.
SendToQueueAsync and MissionControlViewModel.EnqueueTaskAsync (drag-to-queue)
wrote Status=Queued straight via EF, bypassing TaskStateService entirely and
its manual/draft-child guards. That let an autonomous claude process spawn in
the same worktree a user was hand-editing in the ConPTY pane.

Add !HasInteractiveSession to CanSendToQueue, and route both UI enqueue paths
through IWorkerClient.SetTaskStatusAsync (worker hub -> TaskStateService.
EnqueueAsync) instead of raw EF writes. The interactive-session gate itself
stays in the UI: the worker has no notion of a UI-hosted ConPTY pane.
2026-08-06 13:29:44 +02:00
mika kuns 6118698d8c docs(explore-notes): document interactive session id persistence and resume precedence 2026-08-06 13:28:04 +02:00
mika kuns 1a988ff4fc feat(worker,data): persist interactive session id so a closed/aborted ConPTY session can be resumed
Generates the claude session id up front (--session-id <guid>) for a fresh interactive task
session and persists it to TaskEntity.InteractiveSessionId before launch, so an abort at any
point still leaves a resumable id. BuildForTaskAsync now resumes this task's own last
interactive session in preference to the latest autonomous run's session, but never across a
freshly (re)created worktree.
2026-08-06 13:27:52 +02:00
mika kuns 9e2a15e421 fix(worker,ui): route details-pane task delete through worker to advance blocked parents
Deleting a child from the details pane hard-deleted straight from the UI
process via TaskRepository, bypassing TaskStateService.TryAdvanceParentAsync
entirely. Deleting the last child of a WaitingForChildren parent that way
left it wedged forever. WorkerHub.DeleteTask now mirrors the MCP delete_task
tool (running-task guard, FK-friendly error, advance-parent call), and the
UI goes through it.

TryAdvanceParentAsync also short-circuited when zero children remained,
treating "no children left" as "nothing to evaluate" instead of "all done" -
removed the early return so an empty child list (vacuously) counts as all
terminal.
2026-08-06 13:25:49 +02:00
mika kuns 1649291ef0 docs(explore-notes): document stale-Running chain/parent advance fix
Bump worker-task-pipeline verified-against commit to 58741c2.
2026-08-06 13:17:07 +02:00
mika kuns 58741c2bd6 fix(worker): advance planning chains and parents on stale-Running recovery
RecoverStaleRunningAsync bulk-flipped Running rows to Failed via raw
ExecuteUpdate, skipping the chain/parent side effects every other terminal
transition triggers. After a worker crash mid-run of a planning/improvement
child, the chain successor's BlockedByTaskId was never cleared and a
WaitingForChildren parent could wedge forever with no event left to
re-check it. Now each recovered task runs the same OnChildTerminalAsync
side effects (chain advance + parent advance) as FailAsync, best-effort.
2026-08-06 13:16:58 +02:00
mika kuns 7affb4c204 feat(ui): repo scan discovers nested repos in subfolders
RepoScanner.Scan now recurses into subdirectories (max depth 5) instead
of only checking the immediate children of the chosen folder. A found
repo (.git as dir or file) is added without descending further; the
selected folder itself is checked too. Skips node_modules/bin/obj/.git/
.vs/packages and reparse points (junctions/symlinks). Per-directory
IOException/UnauthorizedAccessException no longer aborts the whole scan.
2026-08-06 13:14:56 +02:00
mika kuns 0d1e3b9a6f docs(explore-notes): document the kill-on-detach gotcha and ConPtyPaneHost reparenting 2026-08-06 12:57:42 +02:00
mika kuns aac84e48b7 fix(ui): keep ConPTY sessions alive across Mission Control layout rebuilds
TerminalView kills its child process on any logical-tree detach unless
BeginReparent() suppressed it, and RebuildOverviewGrid() detaches every
existing pane whenever one is added - so opening a second interactive
session killed the first. Focus-mode tab switches had the same defect.

PtyTerminalSession now puts the control in permanent reparent mode after
launch (teardown stays explicit via ConPtyPaneViewModel.Dispose -> Kill),
and the new ConPtyPaneHost reparents each pane's single long-lived view
across grid rebuilds and tab switches instead of letting the DataTemplate
instantiate a dead replacement.
2026-08-06 12:43:55 +02:00
mika kuns 0878bae19e docs(explore-notes): point both drift checks at the merged state 2026-08-06 12:12:12 +02:00
mika kuns 20bce9b424 Merge branch 'claudedo/75514ee68371402b814638290af47cca' 2026-08-06 12:11:38 +02:00
mika kuns 5d1d2d89d0 fix(ui): suppress auto-open conflict resolver during MCP-driven merges
review_task/continue_merge on a planning parent always leaves conflicts in
the tree, and the UI auto-opened the resolver on every PlanningMergeConflict
broadcast regardless of who started the merge -- so a running Claude session
resolving a unit-merge conflict could race a human editing the same shared
checkout in a resolver window neither of them asked for.

PlanningMergeOrchestrator.StartAsync now takes an externallyDriven flag (set
by ExternalMcpService's MCP-driven review_task path, left false for the UI's
ApproveReview) that rides along on the PlanningMergeConflict broadcast. The
UI only auto-opens the resolver when it's false; otherwise it shows a
persistent banner with a manual "Open resolver" button, cleared on
PlanningMergeAborted/PlanningCompleted. A new GetActiveExternalConflictsAsync
query (checked against GitService.IsMidMergeAsync rather than the in-memory
flag alone) lets the UI resync the banner on reconnect instead of trusting a
one-shot broadcast that isn't replayed after a restart.

The childless single-task conflict path was checked and needed no change --
it only broadcasts the generic TaskUpdated, never PlanningMergeConflict.
2026-08-06 12:04:52 +02:00
mika kuns 0358bcf8b2 Merge branch 'claudedo/001ee94a1d8d46b0906f7466ea6beb32' 2026-08-06 12:04:47 +02:00
mika kuns c6213d77c4 fix(installer): release the ShellLink COM object so reading a .lnk doesn't lock it 2026-08-06 12:00:18 +02:00
mika kuns e59f6c2438 fix(worker): surface empty review ranges and blocked children over MCP
preview_merge/preview_merge_set now report isEmpty (ahead==0, or
HandlerBaseCommit==HandlerHeadCommit for a worktree-less list-handler
task) instead of leaving an empty branch indistinguishable from a small
one. preview_merge also stops throwing for worktree-less handler tasks,
falling back to their fixed commit range. review_task's parent approve
returns emptyChildren, naming the Done children whose review range
contributed nothing before the unit merge lands. TaskRefDto/TaskDto now
expose roadblockCount so a CLAUDEDO_BLOCKED child is identifiable over
MCP, since it still reaches Done per the unified parent model.
2026-08-06 11:54:46 +02:00
mika kuns 46e177eb59 Merge claudedo/24acb89f61094773a0dc76d31bdae274 2026-08-06 11:43:19 +02:00
mika kuns abac5e5150 Merge claudedo/c97dcaafbb3542bda9c670e502ffffb6 2026-08-06 11:43:07 +02:00
mika kuns 7240b39f16 Merge claudedo/3a3e87648400492bba6e9643007703db 2026-08-06 11:41:55 +02:00
mika kuns 4a60c2bfa8 Merge branch 'claudedo/0733742e0da8407d8e2be1bc5886bf9b' 2026-08-06 11:40:47 +02:00
mika kuns 03c4ed4607 docs(explore-notes): point external-mcp drift check at the merged state 2026-08-06 11:39:12 +02:00
mika kuns f3e16412f4 Merge branch 'claudedo/c1d2a92d8d8c4705ac93d5f0b2cacaa0' 2026-08-06 11:38:32 +02:00
mika kuns db4177db83 docs(worker): evaluate planning-chain fork-base options, recommend fork-from-predecessor 2026-08-06 11:37:37 +02:00
mika kuns 8bc7bc0c4f feat(ui): warn when the running worker predates the selected repo's merged HEAD
Stamps ClaudeDo.Worker's build with its exact git SHA (SourceRevisionId ->
InformationalVersion) and exposes it via a new GetWorkerBuildInfo hub call.
For the currently selected list, the shell compares that SHA against the
list's git HEAD (GitService.IsAncestorAsync) and shows a persistent footer
banner -- never auto-clearing, never shown on an unrelated repo or when the
ancestry can't be determined -- so "verified against a merge" claims aren't
silently made against a stale process. No auto-restart; the banner just
offers the existing RestartWorkerCommand.
2026-08-06 11:36:26 +02:00
mika kuns af16830060 feat(worker): add treatWaitingForChildrenAsBusy to wait_for_task_change
WaitingForChildren already counted as "changed" since it's outside Queued/Running,
so waiting on a planning parent returned immediately even though its children were
still running. The new opt-in flag (default false, unchanged behavior) keeps polling
through WaitingForChildren and only reports changed once the parent reaches
WaitingForReview or a terminal status.
2026-08-06 11:32:00 +02:00
mika kuns b54a133c16 fix(worker,ui): clean up three review leftovers from the list-handler run
Remove the redundant TaskUpdated broadcast in TaskRunner.ContinueAsync's
queue-claim path, consolidate InteractiveLaunchSpecService's seven
MCP_TOOL_TIMEOUT literals into one constant (fixing the merge-helper handoff
spec's stale 200000ms value), and surface OpenQuickClaudeSession's two
failure cases via ErrorReported/footer instead of a silent no-op, with a
less ambiguous icon.
2026-08-06 11:31:03 +02:00
mika kuns 8247a749a0 Merge claudedo/f9ee427d9f644715bafb55857ad8c3e8 2026-08-06 11:29:49 +02:00
mika kuns f8c48e2ed7 fix(worker): make list_tasks/batch_get_tasks lean by default
list_tasks on a list of ~100 verbosely-described tasks could return
390k+ characters in one call, blowing past the caller's token limit.
Both tools now default to lean TaskRefDto references (no
Description/Result) and take an includeDescription flag to opt back
into the full TaskDto payload — same flag-alongside-nullable-payload
idiom already used by BatchGetTaskResult/TaskConfigResult. get_task is
unchanged.
2026-08-06 11:25:32 +02:00
mika kuns 3e07536ee9 feat(worker): warn on near-duplicate titles in add_task/batch_add_tasks
add_task and batch_add_tasks now report up to 3 open (non-terminal)
tasks in the same list with a strongly overlapping title, so a
parallel agent can notice and mention a likely duplicate instead of
silently creating one. The task is always created regardless. Uses a
cheap normalized-word overlap heuristic (no embeddings/LLM call),
robust to German umlaut/digraph spelling variants. Breaking change:
AddTask now returns AddTaskResult { task, possibleDuplicates } instead
of a bare TaskRefDto; BatchAddTaskResult gained a PossibleDuplicates
field.
2026-08-06 11:24:17 +02:00
mika kuns 6980aae77e Merge claudedo/64fbe15dae7e4d81b8c1045baa7e3c87 2026-08-06 11:22:35 +02:00
mika kuns 75561930d7 chore(claude-do): RepoImportFolders auf den WorkerHub-Pfad umziehen (Ui schrei
## Befund (am Code verifiziert 2026-08-06)

`AppSettingsEntity.RepoImportFolders` (`src/ClaudeDo.Data/Models/AppSettingsEntity.cs:26`, JSON-Array als TEXT) ist **nicht** Teil des `AppSettingsDto` in `src/ClaudeDo.Worker/Hub/WorkerHub.cs:32-49` und wird weder von `GetAppSettings` (~:375-393) noch von `UpdateAppSettings` (~:396-424) transportiert.

Stattdessen liest und schreibt die Ui das Feld dire

ClaudeDo-Task: 64fbe15d-ae7e-4d81-b8c1-045baa7e3c87
2026-08-06 11:18:00 +02:00
mika kuns b66ce580de Merge claudedo/ebd5a205ecfe40c298e3995f9f92f0a1 2026-08-06 11:14:37 +02:00
mika kuns 70322c203c Merge claudedo/7e54be27bc474883bc2389802123f55e 2026-08-06 11:13:30 +02:00
mika kuns db1775fb69 Merge claudedo/94ca2f10e45242ad9a570cb694e76aa3 2026-08-06 11:12:53 +02:00
mika kuns 76dfbc87eb Merge claudedo/4606dadca84644ceb35dc01eaebe22de 2026-08-06 11:12:05 +02:00
mika kuns 7cfe280a23 Merge claudedo/0cb3cb17e7fa4507a7ae0f302f2c4522 2026-08-06 11:10:00 +02:00
mika kuns e38fcec8cc chore(localization): remove unused installer.selfUpdate keys 2026-08-06 11:09:52 +02:00
mika kuns 423ab7f2a9 chore(ui): remove dead AgentStripView fragment
AgentStripView was superseded by WorkConsole during the task-detail
redesign but never wired into any view and never deleted. Drops the
unused Border.agent-strip / diff-meter styles and the 14 agent.* locale
keys that only it referenced, and marks A4 in the feature-unification
spec as resolved by deletion.
2026-08-06 11:09:32 +02:00
mika kuns 2ad9bdd851 Merge claudedo/6678d1ab7da0460282f4e1c2b58dea85 2026-08-06 11:09:30 +02:00
mika kuns 46c664a03f fix(ui): restore ForegroundHelper.AllowAny call before wt.exe launch
OpenInTerminal lost its foreground-grant call, leaving ForegroundHelper
without a caller. Restore it ahead of the Process.Start (covers both the
wt.exe and cmd.exe fallback paths) instead of deleting the helper.
2026-08-06 11:09:25 +02:00
mika kuns 445242cd7d fix(installer): skip rewriting the autostart shortcut when already current
RegisterAutostartStep rewrote the Startup .lnk on every install/update/repair
even when it already pointed at the right worker exe. AutostartShortcut.Install
now reads the existing shortcut's target via ShortcutFactory.TryGetTarget and
skips the rewrite when it matches, reporting the skip in progress output.
Legacy service/scheduled-task cleanup stays unconditional (migration safety net).
2026-08-06 11:08:15 +02:00
mika kuns 86f962ee7d test(installer): prove RegisterMcpStep still resolves from the DI container 2026-08-06 11:07:48 +02:00
mika kuns 68aa2f5f4b fix(ui): remove duplicate list-settings context menu entry
List settings were reachable via three doors (header button, context
menu, double-click). Drop the context menu entry, matching the same
cut already made for the "Let Claude handle it" entry point.
2026-08-06 11:07:31 +02:00
mika kuns 76b748060c chore(ui): remove unused CheckboxBorderConverter, DateOnlyToDateTimeConverter, StatusColorConverter/ConnectionColorConverter 2026-08-06 11:06:57 +02:00
mika kuns 175f160956 Merge claudedo/5f664041b046456d8e752f90d193c906 2026-08-06 11:06:47 +02:00
mika kuns cfd2936c25 Merge claudedo/610f2deab9a94f2ca2c3e9b2b0652f19 2026-08-06 11:04:45 +02:00
mika kuns 3462ca1355 fix(installer): read the persisted MCP port on update instead of the wizard default
RegisterMcpStep built the registration URL from ctx.ExternalMcpPort, which the
Update pipeline never repopulates from the existing installation, so any
update silently re-registered the wizard default (47822) even when
worker.config.json had a different port configured. InstallerWorkerConfig was
also missing external_mcp_port entirely, so the installer had no way to read
it back. Port 0 (external listener disabled) now skips registration instead
of pointing Claude at 127.0.0.1:0/mcp.
2026-08-06 11:04:38 +02:00
mika kuns e717c901b2 fix(installer): show the MCP registration step in the progress list
Update and FreshInstall both run RegisterMcpStep but the UI's Steps
list never included it, so the running step never appeared and
Steps.Count didn't match the executed pipeline length.
2026-08-06 10:58:24 +02:00
mika kuns 0f187d8e82 docs: record the verify-gate reach and the NumericUpDown null trap
review-merge: worktree-less approvals run the gate too, and all three UI merge
entry points handle verify_failed explicitly.
Ui gotchas: NumericUpDown.Value is decimal? and nulls while the box is empty.
2026-08-06 10:22:17 +02:00
mika kuns f106c890b3 fix(ui): refresh the run session id live, close the stale handoff pane
A task selected before its run started kept LatestRunSessionId null, so the
roadblock reply box and Continue stayed dead until it was re-selected.

The handoff left the Phase 1-2 tile open so its last message could be read, but
its process is gone by then and the terminal renders empty -- a dead
placeholder. It is closed on handoff now.

Also drops the WARN flood from worktree cleanup (already-unregistered worktree
and already-deleted branch are normal outcomes, not failures) and replaces the
fixed sleep in UsageGate_TransitionLogging_FiresOncePerChange with the polling
helper that already sits three lines below it in the same file.
2026-08-06 10:20:54 +02:00
mika kuns 56f7d64f07 fix(ui): stop NumericUpDown from writing null into non-nullable settings
Clearing the text box to type a new value sets Value to null, which the TwoWay
binding then wrote into an int/decimal target -- InvalidCastException on the
normal way of editing eight settings fields. KeepLastNumberConverter maps that
null to BindingOperations.DoNothing so the source keeps its last value.
2026-08-06 10:20:53 +02:00
mika kuns 091aca521f fix(merge): apply the verify gate to worktree-less approvals and report it everywhere
ApproveAndMergeAsync short-circuits to Done whenever a task has no active
worktree -- which is exactly how a list-handler task works, since it commits
straight into the list working dir. The verify command was skipped for the run
that lands the most on the target branch at once; the loaded command was even
discarded at the destructuring. It now runs under the same per-repo gate as the
merge path before the task may reach Done.

The two merge entry points that did not know verify_failed reported it as
"Unknown status: verify_failed" (merge modal, dropping the command output) and
as a generic Failed (worktrees batch, claiming the merge never happened).
2026-08-06 10:20:53 +02:00
mika kuns 730ecb1abc docs(open): mark the usage-monitor pass done and drop the light-theme checks
The app is dark-only (App.axaml pins RequestedThemeVariant="Dark", Tokens.axaml
has no light variant), so every "Dark/Light" line was unverifiable by
construction. Adds the roadblock-reply session-id finding from the same pass.
2026-08-06 10:02:14 +02:00
mika kuns b6ecbb13f5 docs(open): record the findings from the 2026-08-06 visual pass
Verified: max-turns hints, usage pill/modal, list-handler run end to end.
New findings: NumericUpDown null binding throws in the settings modal, the
verify gate is skipped for worktree-less tasks and unhandled in two of three
merge entry points, the handoff leaves an empty pane, worktree cleanup logs
already-done as WARN, orphaned git worktrees are invisible to the app, modal
bodies are clipped at the bottom, usage tables need formatting work.
2026-08-06 09:54:15 +02:00
mika kuns b5a8d58e62 docs(open): re-verify the 2026-07-24 findings and drop the fixed ones
Ten of the twelve listed bugs/nits are fixed in the code (outcome JSON,
subtask terminology, live child rows, AskUser in the detail island, Icon.Plus,
gear glyph, rename display, TurnsText, conflict Continue gate, ConPTY resume)
and the blocked-approve silent fail is handled by the WorkerHub throw. Adds the
open verification block for the planning-resume fix.
2026-08-06 09:10:50 +02:00
mika kuns b6791265d1 fix(planning): recover the planning session id from the on-disk transcript
The interactive planning TUI never reports its claude session id back, so
planning_session_id stayed NULL and UpdatePlanningSessionIdAsync had no caller
at all -- ResumeAsync always threw "No Claude session ID captured yet".

Resume now looks the id up in the transcript Claude Code writes to
~/.claude/projects/<encoded cwd>/<sessionId>.jsonl for the planning worktree
(newest file wins; a worktree hosts exactly one session) and persists it, so
the next resume is a plain DB read. No transcript -> a clear "cannot resume"
instead of resuming the wrong session.
2026-08-06 09:10:50 +02:00
mika kuns e113987d2e docs(open): drop the verified 2026-07-27 visual-pass block, keep its design notes 2026-08-06 08:53:02 +02:00
mika kuns 4309c4cb08 docs(open): mark the MaxTurnsCeiling editor as shipped 2026-08-06 08:45:49 +02:00
mika kuns 41c2de5ab0 docs(installer): drop the stale reserved-slot comment now that Claude Help Me shipped 2026-08-06 08:41:46 +02:00
mika kuns a35103418d Merge task branch for: „Claude Help Me"-Button: Claude-Session zur Setup-Fehlersuche starten 2026-08-06 08:37:19 +02:00
mika kuns 1b0ecbe361 Merge branch 'main' into claudedo/4e19605838a3404f98624cb0e90195da
# Conflicts:
#	docs/open.md
#	src/ClaudeDo.Installer/CLAUDE.md
#	src/ClaudeDo.Installer/Pages/SystemCheckPage/SystemCheckPageView.xaml
#	src/ClaudeDo.Installer/Pages/SystemCheckPage/SystemCheckPageViewModel.cs
2026-08-06 08:36:58 +02:00
mika kuns 83ca1fc6dc Merge task branch for: Diagnose-Sektion in der SettingsWindow (Config-Modus) 2026-08-06 08:30:10 +02:00
mika kuns 09e0772673 feat(claude-do): „Claude Help Me"-Button: Claude-Session zur Setup-Fehlersuch
Der Button, der aus „Problem erkannt" ein „Problem gelöst" macht: startet eine interaktive Claude-Session, die dem Nutzer beim Einrichten hilft.

## Warum externes Terminal
Der ConPTY-Stack (`PtyTerminalSession`, `ConPtyPaneView`) liegt in `ClaudeDo.Ui` und ist Avalonia — der Installer ist WPF und referenziert nur Data/Releases/Localization. Beim Fresh Install sind `app\`/`worker\` außerdem noch n

ClaudeDo-Task: 4e196058-38a3-404f-9862-4cb0e90195da
2026-08-06 08:25:36 +02:00
mika kuns 7e1b1177de feat(installer): add Diagnose section to SettingsWindow
Re-runs the environment checks against the installed configuration
(worker.config.json + detected install dir) without blocking navigation
and without auto-running on window open, only on a "Recheck" click.

Extracted the check-row rendering and check-run logic (busy state,
summary, Recheck command) out of SystemCheckPage into a shared
Checks/CheckListViewModel + Checks/CheckListView, composed by both
SystemCheckPage (wizard) and the new DiagnosePage (settings) instead
of duplicating it.
2026-08-06 08:22:45 +02:00
mika kuns b3a8373c70 Merge branch 'claudedo/852c2328ec8b4fc1bf143df203e1dc6c' 2026-08-06 08:01:25 +02:00
mika kuns e7c9ef891f Merge subtask 2026-08-06 07:56:23 +02:00
mika kuns ae6e95d2c0 Merge branch 'claudedo/0c5a46e7b2d84538a54b2e13b44af01e' 2026-08-06 07:56:22 +02:00
mika kuns 8bb1014e54 Merge subtask 2026-08-06 07:54:20 +02:00
mika kuns 66d2dae316 Merge subtask 2026-08-06 07:54:08 +02:00
mika kuns 4988a42620 Merge subtask 2026-08-06 07:54:06 +02:00
mika kuns 7c4bce63d3 Merge subtask 2026-08-06 07:54:04 +02:00
mika kuns ad2d91f658 Merge claudedo/ccd650a8d2b04a7092e81ed07c16dbe0 2026-08-05 22:46:37 +02:00
mika kuns cdfd0614dd Merge claudedo/616befd0f8a64dcb9e3dac9d6499de16 2026-08-05 22:45:32 +02:00
mika kuns 1d258a3e2c Merge claudedo/123b0241b5e94b69bfa592fde89c51aa 2026-08-05 22:44:47 +02:00
mika kuns 23794ed21b Merge claudedo/e3c29bf4538e491e9631ab80076b6fb4 2026-08-05 22:44:05 +02:00
mika kuns 0ce5bad9c1 Merge claudedo/5d8e6246af9444afa500fbb4546873e7 2026-08-05 22:43:41 +02:00
mika kuns f143d5fc18 Merge claudedo/64a2e2653d244714b76d637b3e0f3be8 2026-08-05 22:42:57 +02:00
mika kuns 487e75c031 Merge claudedo/a49fd666577a48b4b7d809bb915dfd48 2026-08-05 22:42:28 +02:00
mika kuns 583c98f2b7 Merge claudedo/f3718cd8a29c48d7910fa39983f1c221 2026-08-05 22:41:58 +02:00
mika kuns adc0eaebb1 Merge claudedo/01556d1f8af54fe2afa4bced3a209121 2026-08-05 22:41:44 +02:00
mika kuns c25300526e Merge claudedo/f67f39609d144607aceb8e3521d57ff7 2026-08-05 22:41:21 +02:00
mika kuns 49f78d2b8d Merge claudedo/da69bd1770ff4bc9930582a717f1b3c9 2026-08-05 22:40:49 +02:00
mika kuns a6af90ff0d docs(installer,worker): document Environment Checks + note unmerged prerequisite gap
Documents the Checks/ + SystemCheckPage feature (gating rule, check list,
FreshInstall-only placement) and the ExecutableResolver/.cmd-shim wiring in
ClaudeDo.Worker's ClaudeCliPreflight/ClaudeProcess. Adds docs/explore-notes/installer-preflight.md
(bumped/corrected against the actual implementation) and links it from the
explore-notes README and root CLAUDE.md.

The underlying code lives only on unmerged task branches (06aca9b3.../40272c0b...),
not on main yet, and two follow-up features ("Claude Help Me" button, Config-mode
Diagnose section) were never implemented because they blocked on that same missing
merge. Both gaps are called out explicitly in the new docs and in docs/open.md's
verification checklist, instead of being asserted as done.
2026-08-05 21:19:21 +02:00
mika kuns d1df54cf5c Merge claudedo/ed464c32c69248d58983b5358a316f21 2026-08-05 21:03:28 +02:00
mika kuns be378ae0ea Merge claudedo/b259a1c9a73846ce833f0b555fb1de79 2026-08-05 21:02:44 +02:00
mika kuns 23b282573c Merge claudedo/9f504bc8ea254eddb3def38a606eb8a5 2026-08-05 21:02:26 +02:00
mika kuns 6887b1d08d fix(ui): make Files-tab system prompts view-only, drop external-editor edit path
Editing a prompt file (even externally via "Open in editor") freezes it as a
user customization and blocks future default updates. The Files tab no longer
opens an external editor; it shows each prompt's content read-only in-app via
a new ViewPromptCommand, dropping the file-seeding side effect. Reset to
default and the customized-prompts deviation list are unchanged.
2026-08-05 21:00:34 +02:00
mika kuns 860201017c fix(worker): broadcast TaskUpdated on queue-claimed task start
QueuePicker's raw-SQL Queued->Running claim bypasses TaskStateService.StartRunningAsync,
the only other place that broadcasts TaskUpdated on this transition, so the task-list
badge stayed on "Queued" until the run finished. Send TaskUpdated for alreadyClaimed
dispatches in TaskRunner.RunAsync/ContinueAsync to close that gap.
2026-08-05 20:53:55 +02:00
mika kuns df16989435 fix(ui): remove duplicate Let-Claude entry point and icon collision
Context-menu "Let Claude handle it" on list rows duplicated the header
button; both routed to the same ListsIslandViewModel command via the
shell, so only the context menu entry (and its now-unused locale key)
is removed. The header button also shared Icon.Broom with Clear Day —
gave it Icon.AgentSuggested instead.
2026-08-05 20:51:19 +02:00
mika kuns d43b5fcefc feat(worker-mcp): raise wait_for_task_change timeout, expose queue slot state
MaxTimeoutSeconds was 170s against runs that take tens of minutes, forcing
a dozen full-context wait rounds per long-running batch. Raise it to 900s
and raise MCP_TOOL_TIMEOUT in lockstep (ClaudeProcess + every
InteractiveLaunchSpecService launch spec) to 930000ms so the client
connection actually stays open that long instead of aborting first.

Add get_queue_state (QueueStateMcpTools): configured vs. effective
parallel-slot count (via QueueService.GetSlotCountsAsync, extracted from
the former GetEffectiveMaxParallelAsync), active slots with taskId +
startedAt including the run_task_now override slot, and queued tasks in
pick order -- so a caller can observe queue occupancy instead of inferring
it from maxParallelExecutions.
2026-08-05 20:47:57 +02:00
mika kuns 29bd1b5069 fix(worker): filter EF Core/ASP.NET Core noise out of log ring buffer
EF Core logs every SQL statement at Information, which BroadcastLogSink
buffers into LogRingBuffer regardless of level, flooding the Log
Visualizer overlay and worker log file with SQL noise that buries
hand-curated business events. Override both to Warning so real
EF Core / ASP.NET Core problems still surface.
2026-08-05 20:46:57 +02:00
mika kuns a7d95a000a fix(prompts): clarify worktree commits aren't auto-commits 2026-08-05 20:42:45 +02:00
mika kuns beef57fba2 docs(claude-md): break growing enumerations into one entry per line
Two remaining single-line lists that accumulate entries over time
(table names in Data/CLAUDE.md, HubBroadcaster event names in
Worker/CLAUDE.md) collide when parallel tasks each append a new
entry, same as the AppSettingsEntity/migration-list lines fixed
earlier today. Content unchanged, one bullet per existing item.
2026-08-05 20:42:10 +02:00
mika kuns a768bc4163 feat(worker): add get_effective_run_config MCP tool
Adds a read-only get_effective_run_config(taskId) tool that reports the
model/max-turns/effort/permission-mode/agent-path/system-prompt/skills a
task will actually run with, each tagged with its source (task/list/
preset/global), plus max-turns' raw requested value and clamp status.

Extracted the model/max-turns/agent-path resolution out of
TaskRunner.ResolveConfigAsync into EffectiveRunConfigResolver so the run
path and the new reporting tool share one codepath instead of risking
drift, per docs/explore-notes/worker-task-pipeline.md's max-turns trap.
2026-08-05 20:39:35 +02:00
mika kuns ecba12997a perf(worker): stop echoing task description from writing MCP tools
update_task, update_task_status, add_task, add_subtask, set_my_day,
abort_merge, review_task, and their batch variants now return a lean
TaskRefDto (id/listId/title/status/sortOrder/isMyDay) instead of the
full TaskDto. Those tools were re-sending the caller's own description
text back on every call, wasting a large share of session context on
list-handler-style runs. get_task/list_tasks/batch_get_tasks are
untouched and still return the full DTO.
2026-08-05 20:37:30 +02:00
mika kuns bee06bc307 docs(claude-do): Prompt-Audit: bekommen interaktive und autonome Sessions den
## Der Zweifel

Ein autonomer Agent und eine interaktive Session sollen sich unterschiedlich verhalten. Unklar war, ob beide denselben System-Prompt mitbekommen.

## Vorbefund (2026-08-05, beim Verfeinern erhoben — **selbst nachpruefen, nicht uebernehmen**)

Sie teilen ihn **nicht**. Pro Startweg:

| Startweg | Einstieg | System-Prompt |
|---|---|---|
| Autonomer Run | `ClaudeArgsBuilder.cs:69-73`

ClaudeDo-Task: b259a1c9a73846ce833f0b555fb1de79
2026-08-05 20:33:04 +02:00
mika kuns 4ef01274f9 feat(list-handler): hand off to a fresh session after Phase 2
The merge-helper ("Let Claude handle it") system prompt now calls a new
handoff_list_handler MCP tool once every surviving task is enhanced,
instead of continuing into Phases 3-5 in the same session -- avoiding
paying for Phases 0-2's dedupe/rewrite context on every polling round of
the run/review/merge phases.

The tool broadcasts HandoffRequested; Mission Control opens a second
ConPTY tile for the SAME handler task id (no new task, HandlerBaseCommit
untouched) running a fresh handoff brief that starts at Phase 3. The
original tile stays open. Adds PromptKind.MergeHelperHandoff,
InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync, and the
GetMergeHelperHandoffLaunchSpec hub method.
2026-08-05 20:27:39 +02:00
mika kuns 8c251c78b1 feat(ui): add copy-last-40-lines button to log visualizer 2026-08-05 20:26:04 +02:00
mika kuns 2975f90f9d feat(settings): make max-turns ceiling editable in General tab 2026-08-05 20:21:05 +02:00
mika kuns 95eba1d5e7 feat(claude-do): Quick-Claude-Button in der Kopfleiste der Tasks-Insel: oeffn
## Ziel

Ein kleines Icon in der Kopfleiste der Tasks-Insel, das fuer die gerade ausgewaehlte Liste **sofort** eine interaktive ConPTY-Claude-Session im Arbeitsverzeichnis dieser Liste oeffnet — ohne Task, ohne Dialog, ohne Ordnerauswahl.

## Ausgangslage (verifiziert 2026-08-05)

Der komplette Unterbau existiert schon; das ist im Kern Verdrahtung:

- `MissionControlViewModel.OpenAdHocConPtySessio

ClaudeDo-Task: 5d8e6246af9444afa500fbb4546873e7
2026-08-05 20:18:22 +02:00
mika kuns 663b09e0a0 test(installer): cover CanGoNext end-to-end through WizardViewModel navigation
Navigates Welcome -> SystemCheck and asserts the Next button's CanGoNext
flips false on a blocking Error+Failed check and back to true once a
recheck comes back clean, exercising the PropertyChanged wiring between
the page and WizardViewModel rather than just the page's own BlocksNavigation.
2026-08-05 20:11:15 +02:00
mika kuns 5cc1ec98c0 feat(installer): add SystemCheckPage with blocking-error gating
Adds a new wizard page (positioned right after Welcome, FreshInstall only)
that auto-runs EnvironmentCheckService on entry and shows one row per check
with status icon, localized title/message, and hint+help-link on
failure/unknown. A "Recheck" button re-runs it, guarded against re-entrancy.

IInstallerPage gets a BlocksNavigation default member; WizardViewModel's
Next button now binds to CanGoNext, which the current page can veto (used
here while a check run is in flight or a blocking Error+Failed result is
present — Warnings and Unknown results never block). The summary line names
the blocking checks so a disabled Next is self-explanatory.

Wires up DI for the check pipeline (IProcessRunner, IPortOwnerResolver,
per-run ClaudeCliLookup) and adds the checks.* / installer.systemCheck.*
locale keys in en.json + de.json.

Visual appearance is NOT verified — needs a manual pass in the running
installer.
2026-08-05 20:09:52 +02:00
mika kuns 05be07b28c feat(installer): pull in preflight check implementations as prerequisite for SystemCheckPage
Git/GitIdentity/Port/WriteAccess and Claude CLI/Version/Auth/PermissionModeAuto
checks plus the ExecutableResolver they depend on were built in two sibling
task branches that hadn't landed on main yet. Vendored the finished files in
from those branches (same content, verified building + tests green) so the
SystemCheckPage task has something to consume.
2026-08-05 19:56:07 +02:00
mika kuns d743a9d0e9 feat(installer): add Claude CLI preflight checks (found, version, login, auto-mode)
Four IEnvironmentCheck implementations in src/ClaudeDo.Installer/Checks/:
- ClaudeCliCheck (Error) — resolves ctx.ClaudeBin via ExecutableResolver, runs
  --version; failure message lists searched PATH entries + fallback dirs, flags
  shim resolution (.cmd/.ps1) in Detail.
- ClaudeVersionCheck (Error) — tolerant version parsing (ignores surrounding
  text), numeric System.Version comparison against a named floor constant
  (2.1.220, see docs/explore-notes/installer-preflight.md §3). Unparseable or
  missing CLI -> Unknown, never Failed.
- ClaudeAuthCheck (Error) — `claude auth status --json`, never sends a prompt.
- PermissionModeAutoCheck (Warning) — per the note's §2 conclusion, real
  auto-mode eligibility (org/model/plan) has no cheap static signal, so this
  only confirms `claude --help` still lists "auto" as a --permission-mode
  choice. Kept as its own check rather than folded into ClaudeVersionCheck,
  since the note explicitly separates "flag recognized" from "mode eligible"
  and only the former is checkable at all.

All four share a new ClaudeCliLookup that resolves the CLI and runs
--version exactly once per install run (memoized, semaphore-guarded), so
none of the three version-dependent checks repeats that call.

Foundation prerequisites this task depends on (docs/explore-notes/
installer-preflight.md, ExecutableResolver, the IEnvironmentCheck/CheckResult/
EnvironmentCheckService abstraction, ProcessCommand, IProcessRunner) existed
only on an unmerged sibling branch, not on main. Rather than merging that
whole unreviewed branch, the specific prerequisite files were copied in
as-is (git history shows their origin). GitCheck/GitIdentityCheck/PortCheck/
WriteAccessCheck from that branch were intentionally NOT brought in — out of
scope for this task.

Deviations/decisions worth flagging:
- Added ExecutableResolver.FallbackDirectories() (public) so ClaudeCliCheck
  can name the checked fallback locations in its failure message; the prior
  branch kept that list private.
- Paths.cs now qualifies System.Environment explicitly. Adding the
  ClaudeDo.Data.Environment namespace makes bare `Environment.X` inside any
  ClaudeDo.Data.* namespace resolve to the sibling namespace instead of
  System.Environment (C# prefers nested/enclosing namespace members over
  usings) — this broke the build until qualified.

Not done (explicitly out of scope): no DI wiring into the wizard UI, no
XAML — matches how the prerequisite Git/Port/WriteAccess checks were also
left unwired.
2026-08-05 19:43:34 +02:00
mika kuns 45bc324402 feat(installer): add Git, GitIdentity, Port, and WriteAccess preflight checks
Implements IEnvironmentCheck for the four checks derivable without a
Claude CLI probe:
- GitCheck (Error) - resolves git via ExecutableResolver (handles .cmd
  shims), parses `git --version`.
- GitIdentityCheck (Warning) - user.name/user.email presence; Unknown
  (not Failed) if git itself is missing, so it doesn't duplicate GitCheck's
  failure.
- PortCheck (Warning) - loopback bind probe for SignalRPort/ExternalMcpPort;
  resolves the owning process via a new NetstatPortOwnerResolver and treats
  a port held by the running ClaudeDo.Worker (update/repair case) as Ok.
  Both ports are configurable, hence a warning.
- WriteAccessCheck (Error) - create+delete a probe file in InstallDirectory
  and ~/.todo-app (walking up to the first existing parent), not an ACL
  read (ACLs lie on virtualized paths).

Process calls go through a new IProcessRunner wrapping the existing static
ProcessRunner, so checks are fakeable in tests instead of spawning real
processes.

DotnetRuntimeCheck was intentionally not added: per
docs/explore-notes/installer-preflight.md, App/Worker publish
self-contained (no preinstalled runtime needed), and the Installer's own
.NET 8 Desktop Runtime requirement is self-proving - a framework-dependent
apphost can't reach managed code at all if that runtime is missing, so a
check running from inside the process can never observe a failure.

Brings in two prerequisite commits this task builds on that hadn't reached
this branch yet: the IEnvironmentCheck/EnvironmentCheckService scaffolding
and the installer-preflight.md research note.
2026-08-05 19:25:02 +02:00
mika kuns 7fa43b5737 Merge branch 'claudedo/40272c0bb3b14562b59c022d09c382b6' into claudedo/c7f96c94227943c8ac7dcf7f3e1cc1a6 2026-08-05 19:15:30 +02:00
mika kuns 30bc12b0ac Merge branches 'claudedo/8e16af76482d423594fb04686caa8097' and 'claudedo/190b7a48d56a4c3799813fb0baed2c4b' into claudedo/c7f96c94227943c8ac7dcf7f3e1cc1a6 2026-08-05 19:14:48 +02:00
mika kuns 8b26e23d73 feat(installer): add check abstraction and EnvironmentCheckService
Scaffolding for environment checks: IEnvironmentCheck, CheckResult/
CheckSeverity/CheckStatus, and EnvironmentCheckService that runs checks
in parallel while preserving input order, never throws (a failing
check becomes Unknown), and reports HasBlockingError only for
Error+Failed.
2026-08-05 19:11:28 +02:00
mika kuns e88f9d01e6 fix(worker): resolve claude CLI shims (.cmd/.bat) not just .exe on PATH
UseShellExecute=false only appends .exe when searching PATH, so an
npm-installed claude.cmd was never found even though it works from a shell.
Adds a shared ExecutableResolver in ClaudeDo.Data (PATH/PATHEXT aware, with
known npm/claude install-dir fallbacks) and wires it into ClaudeCliPreflight
and ClaudeProcess; shims are launched via cmd.exe /c.
2026-08-05 19:08:20 +02:00
mika kuns dce42a9c22 docs(explore-notes): root-cause --permission-mode auto + CLI/.NET preflight research 2026-08-05 18:57:07 +02:00
mika kuns bdee731376 Merge branch 'claudedo/f359858ac98a439593e459df9c5d0a5d' 2026-08-05 16:49:08 +02:00
Mika Kuns d15aa27707 fix:Improve Claude Mds 2026-08-05 16:46:02 +02:00
mika kuns 2700c3d817 fix(usage): stop 429s with an activity-dependent poll cadence + manual refresh
The usage monitor polled the undocumented OAuth usage endpoint every 60s and
earned 429s. It now polls every 5 min while any task is Running and every
15 min while idle (usage_poll_interval_active_seconds / _idle_seconds, both
clamped to >= 60; the old single usage_poll_interval_seconds key is gone).

A 429 comes back as UsageRateLimitedException carrying Retry-After and adds
exponential backoff on top, capped at 30 min and never shorter than the normal
cadence; the strike count resets on the first success. The schedule arithmetic
is the pure static UsagePollSchedule.NextDelay.

Since the idle cadence is slow on purpose, WorkerHub.RefreshUsage drives
UsageMonitorService.RefreshNowAsync behind a Refresh now button in the Usage
Monitor modal: an out-of-band poll that pushes the loop's next-due time out so
no double poll follows, with a 10s cooldown so click-spam can't earn a 429.

Staleness now measures against the slower (idle) interval so an idle worker
isn't flagged stale just for not polling.
2026-08-05 16:40:34 +02:00
mika kuns f6cb8250bb fix(tests): pass FakeTranscriptUsageReader to TaskRunner in FailureDiagnosisTests
The cache-token task added an ITranscriptUsageReader ctor param to TaskRunner
while the failure-diagnosis task added this test file. Both branches were green
in isolation; the merged result did not compile.
2026-08-05 16:15:50 +02:00
mika kuns 79a1403834 Merge branch 'claudedo/87105f5ec4f44af4ae6089cd2e153e3c' 2026-08-05 16:10:37 +02:00
mika kuns 91eb2996c8 Merge branch 'claudedo/2de2f008758640b3a75e95719b1555bf' 2026-08-05 16:07:10 +02:00
mika kuns 19003e68b6 Merge claudedo/38394081d47048fea82317c6c52e01a5 2026-08-05 16:05:19 +02:00
mika kuns 2a217336e8 Merge claudedo/1b599d6711914658b9857b3df996f6e0 2026-08-05 16:03:14 +02:00
mika kuns 18fb483788 Merge claudedo/ca6e55c0992b460e83ff186c076b4315 2026-08-05 16:02:02 +02:00
mika kuns f381ef28fd Merge claudedo/0d0aa8b0fad9484cab532cc19709ec13 2026-08-05 16:01:18 +02:00
mika kuns ca184511ae Merge claudedo/3f39b447914d40d2a90e7505b5aaf432 2026-08-05 16:00:52 +02:00
mika kuns fe9a9596bd Merge claudedo/527b494ae54f4352aec8e381c5afa4f8 2026-08-05 16:00:38 +02:00
mika kuns b153869216 fix(prompts): stop on-disk prompt overrides from freezing forever
EnsureExists blindly seeded ~/.todo-app/prompts/*.md with the bundled
default and never revisited it, so any file created by opening the
Files settings tab shadowed every later default change permanently
(SuggestImprovement/AskUser sections never reached real runs since
2026-06-04). PromptFiles now hashes what a file was seeded/saved with
in prompts/.defaults.json: Classify() tells missing/current-default/
known-past-default/edited apart, ReconcileStaleDefaults() drops files
that only ever matched a superseded default, and real edits are left
alone and surfaced in the Files tab with a diff + reset-to-default
action. QuarantineOrphans() moves stale-named leftovers (agent.md,
planning.md) into prompts/_orphans instead of silently deleting them.
Wired as a Worker startup sweep (PromptFileRecovery) alongside the
existing OrphanRecovery/AttachmentOrphanRecovery services.
2026-08-05 15:59:03 +02:00
mika kuns 2ebdadff08 fix(worker): surface the real reason a Claude run failed instead of a generic exit-code message
StreamAnalyzer now reads subtype/terminal_reason/errors from the CLI's result
event, and TaskRunner builds a specific ErrorMarkdown from them: max_turns names
the turn budget and points at set_task_config + requeue, api_error passes
through the provider's own message (which carries the reset time), and any
other terminal_reason is appended to the previous generic text instead of
staying invisible. Falls through to the old "exited with code N and no result"
text when there's no terminal_reason at all (a real crash).
2026-08-05 15:55:09 +02:00
mika kuns b1e4e543dd docs(localization): rename ConPTY session UI text to interactive session 2026-08-05 15:54:57 +02:00
mika kuns a201d3f43d chore(claude-do): UsageGate: Parallelitaet stufenweise drosseln statt erst bei
## Kontext: Limits sind Fenster, nicht Summen

Die Runs laufen ueber das Claude-Abo. Limits greifen pro 5h-Fenster und pro 7 Tage. Nicht die Wochensumme tut weh, sondern dass ein Agent-Burst ein Fenster leerraeumt, in dem Mika selbst interaktiv arbeiten will.

## Messgrundlage (alle Transcripts unter ~/.claude/projects)

Agent-Runs sind ueber die ganze Historie nur **18,4 %** des Account-Verbrauch

ClaudeDo-Task: 87105f5e-c4f4-4af4-ae60-89cd2e153e3c
2026-08-05 15:53:15 +02:00
mika kuns 7d3d6d7b54 fix(worker): record real raw token usage per run, not the uncached remainder
task_runs.tokens_in only ever held the API's uncached "input_tokens" field
(off by a factor of ~400,000 on a resumed session), and tokens_out summed
only the last result event instead of the whole session. TaskRunner now
reads each run's cache-read/cache-write/input/output totals from the
session transcript via a new ITranscriptUsageReader.ReadSessionTotalsAsync,
storing the delta against prior runs on the same session so a --resume
doesn't double-count. New task_runs.cache_read_tokens/cache_write_tokens
columns; the Session tab now shows the raw total (what actually counts
against the 5h/7d usage limit) with a breakdown tooltip.
2026-08-05 15:44:20 +02:00
mika kuns 08ac8bf7b1 feat(worker): clamp max-turns to a configurable ceiling
Runaway sessions were the single biggest cost driver: model_presets was
never persisted (stayed code-only), default_max_turns shipped at 100, and
ResolveMaxTurns had no upper bound, so a task/list override could run
hundreds of turns unchecked.

- TaskRunner.ResolveMaxTurns now clamps the resolved value to
  AppSettings.MaxTurnsCeiling (new column, default 80) and logs a warning
  with task id / requested / effective value when it clamps.
- default_max_turns default lowered from 100 to 40 (entity, EF config,
  and the seeded row via the new AddMaxTurnsCeiling migration).
- AppSettingsRepository.GetAsync backfills model_presets with the
  shipping defaults on first read instead of leaving the column null.
- Settings > General's per-model preset table and the task/list agent
  editor now show a hint when a set max-turns value exceeds the ceiling.
2026-08-05 15:40:02 +02:00
mika kuns 3ea48ff76d feat(mission-control): make overview panes and queue strip individually resizable
Replace the UniformGrid (all tiles forced to equal size) with a real
Grid + GridSplitters built in code-behind per pane count/column count,
so panes resize individually. The queue strip's fixed 210px column
becomes a drag-resizable Grid column (min 160px) that collapses to 0
when nothing is queued, replacing the old dock-based fixed width.
2026-08-05 15:29:00 +02:00
mika kuns f02b7d6ee5 feat(prompts): add reading-discipline section to default system prompt
Adds a "Reading efficiently" section to SystemDefault covering
locate-before-read (Grep/Glob), targeted reads (offset/limit), avoiding
re-reads, and using an exploration subagent as a context firewall for
orientation questions. Based on measurement across 144 real sessions
showing context resend (Read especially) dominates token cost.
2026-08-05 15:19:15 +02:00
258 changed files with 18273 additions and 1363 deletions
+6 -3
View File
@@ -11,10 +11,13 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service
- **ClaudeDo.Releases** — Gitea release client (`IReleaseClient`), used by the Ui update check and the Installer
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
Each project has its own `CLAUDE.md` — those are the living per-project docs.
Per-project `CLAUDE.md` files exist for **App, Data, Installer, Ui, Worker, and Worker.Tests**
those are the living per-project docs. Localization, Releases, and the other five test projects
have none; this file plus the code is all there is for them.
## Tech Stack
@@ -39,7 +42,7 @@ Each project has its own `CLAUDE.md` — those are the living per-project docs.
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done. In the detail pane, **Approve & Merge is gated behind opening the diff**: when there is something to inspect (worktree diff / merged range / children combined diff) the button stays disabled until the diff or combined-diff viewer has been opened once, and re-locks per run (any state change resets it); tasks with nothing to inspect are never gated. The row-level quick-approve in the task list is an intentional bypass.
- Task status flow: `Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled`; a task with children passes through `WaitingForChildren` first. **Approve is the single review+merge action** (no separate "Merge all"), and in the detail pane it's gated behind opening the diff. Full transition table → `src/ClaudeDo.Worker/CLAUDE.md`; merge/review/gate mechanics → `docs/explore-notes/review-merge.md`.
- Worktree state flow: Active -> Merged | Discarded | Kept
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
@@ -84,4 +87,4 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
- `docs/improvement-plan.md` — improvement snapshot from 2026-04-13 (historical)
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
- `CHANGELOG.md` — Keep a Changelog format, maintained on release
- `docs/explore-notes/` — distilled maps of complex subsystems from deep exploration (detail too fine for a CLAUDE.md). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`.
- `docs/explore-notes/` — distilled maps of complex subsystems (detail too fine for a CLAUDE.md, read on demand). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`. Current notes: `worker-task-pipeline`, `usage-monitoring`, `external-mcp`, `review-merge`, `conpty-sessions`, `installer-preflight`.
+11
View File
@@ -10,6 +10,17 @@ These sit **between** the CLAUDE.md files and the code:
too fine-grained for a CLAUDE.md but stable enough to be worth caching. Read on demand.
- **code** — the only source of truth.
## Index
| Note | Covers |
|---|---|
| [worker-task-pipeline](worker-task-pipeline.md) | `TaskRunner` end-to-end: config resolution, worktree, CLI invocation, streaming, commit |
| [usage-monitoring](usage-monitoring.md) | OAuth usage endpoint, gate, throttle, per-run token accounting, usage pill/modal |
| [external-mcp](external-mcp.md) | The `claudedo` MCP tool surface + its two test-enforced conventions |
| [review-merge](review-merge.md) | Approve=merge-unit, verify gate, `MergeCommit`/revert, diff stack, conflict resolver |
| [conpty-sessions](conpty-sessions.md) | Interactive/planning/list-handler launch specs + the arg-flattening gotcha |
| [installer-preflight](installer-preflight.md) | CLI version/login/auto-mode research, the `ExecutableResolver`/shim root cause, and the Installer's `Checks/`+`SystemCheckPage` implementation status |
## Rules
- **Only stable structure.** Flows, responsibilities, entry points, invariants, relative
+257
View File
@@ -0,0 +1,257 @@
# 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.
`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") | `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`.
+213
View File
@@ -0,0 +1,213 @@
# External MCP tool surface
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `20bce9b` (2026-08-06).
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
Claude sessions. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`.
Server name `claudedo`, registered globally by the installer's `RegisterMcpStep`, so callers
need no `--mcp-config`.
**Scope boundary:** these tools cover *starting* and *observing* sessions plus task/list CRUD
and git/merge operations. They deliberately do **not** expose multi-turn control, planning
session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` header.
## Hard conventions (enforced by tests)
1. **Every optional/filter parameter needs a C# default value** (e.g. `string? status = null`).
The MCP schema only marks a parameter optional when it has one — nullability alone does
not do it. `ExternalMcpToolSchemaTests` guards this by reflection.
2. **No tool returns bare `Task` or a nullable payload directly.** An MCP client cannot tell
an empty/omitted response apart from a dropped one.
- *Write* tools return a small confirmation record — `{ ok/deleted/removed/reset/started:
true, <id>, ... }` (`DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`,
`RemoveAttachmentResult`; `SetListConfigResult` / `SetTaskConfigResult` additionally echo
the resulting config so the caller can see which fields were set vs. cleared to null).
- *Read* tools that may have nothing to return use an explicit `Found` / `Available` flag
alongside the nullable payload (`TaskConfigResult`, `BatchGetTaskResult`, `TaskLogResult`).
- The same flag-alongside-nullable-payload idiom also covers "which of two shapes did you
get": `ListTasks`/`BatchGetTasks` take `includeDescription` (default `false`) and return
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
list of verbosely-described tasks from blowing past the response size limit by default.
3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
*"An error occurred invoking 'X'."*
## Tool classes
### `ExternalMcpService` — task CRUD, execution, git
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`,
`CancelTask`, `DeleteTask`.
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
### Other classes
| Class | Tools |
|---|---|
| `BatchMcpTools` | `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees` |
| `ListMcpTools` | `CreateList`, `UpdateList`, `DeleteList` |
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`, `GetEffectiveRunConfig` |
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` |
| `AgentMcpTools` | `ListAgents` |
| `LifecycleMcpTools` | `ResetFailedTask` |
| `AppSettingsMcpTools` | `GetAppSettings` (read-only) |
| `TaskWaitMcpTools` | `WaitForTaskChange` |
| `QueueStateMcpTools` | `GetQueueState` |
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
## Per-tool behaviour worth knowing
**`ListTasks`** — `includeDescription=false` (default) returns lean `TaskRefDto` references in
`tasks` (`tasksFull` null); `includeDescription=true` returns full `TaskDto`s (incl.
Description/Result) in `tasksFull` instead (`tasks` null). Filtering by `createdBy`/`status`
happens before the lean/full projection either way.
**`UpdateTaskStatus`** accepts `Idle` / `Queued` / `Cancelled` / `Done` only.
- `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)` — the
**only** caller that opts into cancelling from `Idle`. `PlanningChainCoordinator` relies on
`Idle` staying a no-op there by default, because a child parked back to `Idle` mid-chain is
a manual opt-out signal.
- `Done` goes through `TaskStateService.ForceSetStatusAsync` (the same unconditional write the
UI's "set status freely" affordance uses) but is **refused** for a task with an active
worktree, since that would skip `review_task`'s merge.
**`AddTask`** — always creates the task; also returns `possibleDuplicates` (up to 3, id/title/status
only, no descriptions) — open (non-terminal) tasks in the *same list* whose normalized title
overlaps strongly with the new one. Cheap word-overlap heuristic (`ExternalMcpService`'s
`FindPossibleDuplicatesAsync`/`NormalizeTitleWords`), no embeddings/LLM call, no blocking —
the caller just gets a heads-up to relay. `BatchAddTasks` carries the same field per item.
**`ReviewTask`** — `approve` / `reject_rerun` / `reject_park` / `cancel` for a
`WaitingForReview` task. Approve is review+merge exactly like the hub's `ApproveReview`: unit
merge for parents, worktree merge into optional `targetBranch` for childless tasks. Conflicts
are reported in `ReviewTaskResult`.
- A parent's approve also returns `emptyChildren`: the `Done` children about to be unit-merged
whose own review range contributed nothing (computed the same way as `PreviewMerge`'s
`isEmpty`, before the merge starts so it reflects what's about to be approved). Surfaces a
child that reported `CLAUDEDO_BLOCKED` and committed no code — previously that child reached
`Done` and merged silently with `changedFileCount: 0`, indistinguishable from a small-but-real
change. `TaskRefDto.roadblockCount` (on every task-returning tool, stamped by `TaskRunner` from
`result.Blocks.Count`) is the MCP-visible signal for *why* a child is empty.
**`PreviewMerge`** — non-destructive `git merge-tree --write-tree` mergeability check for one
task's worktree branch against `targetBranch` (default: the repo's current branch). Returns
status / conflictFiles / changedFileCount / `behind` / `isEmpty`. Unlike
`TaskMergeService.PreviewAsync`'s silent *"unavailable"*, this **throws a clear error** when the
task has neither an active worktree nor a handler commit range, or the list's working dir is
missing.
- `isEmpty` = the review range contributed nothing — zero files changed against the worktree's
base commit, or (for a worktree-less list-handler host task) `HandlerBaseCommit ==
HandlerHeadCommit`. Distinguishes a genuinely empty branch from one that merely made a small
change (`changedFileCount: 0` alone reads as "tiny", not "nothing to review") — the gap that
let two blocked planning children reach `Done` with unmerged empty branches unnoticed.
- A worktree-less handler task has no separate branch to `merge-tree`-preview (its commits
already sit in `list.WorkingDir`) — `PreviewMergeCoreAsync` falls back to a synthetic `clean`
preview over its own `HandlerBaseCommit..HandlerHeadCommit` diff-stat instead of throwing
"has no worktree".
**`PreviewMergeSet`** — same preview for a batch (each entry also carries `isEmpty`), plus a
file→tasks overlap report built from each task's own diff-stat. ⚠️ That overlap report is a
**same-file-name hint only** — it is blind to cross-file collisions (e.g. the CS0103 case that
motivated it). A task that fails to preview gets `error` set and is excluded from the overlap
instead of aborting the batch.
**`RevertMerge`** — undoes a previously merged task's merge commit on `targetBranch` via
`git revert -m 1`. Always a **new commit**, never a reset/rewrite, because the target working
directory is shared with other concurrent sessions.
- Requires the task to be `Done` with a `Merged` worktree carrying a recorded
`WorktreeEntity.MergeCommit`. A task merged before that field existed has none and is
**refused rather than guessed** via `git log`.
- On success the task returns to `WaitingForReview` and the worktree moves to `Kept` — not
`Active` (its directory/branch are typically already gone from the original merge's cleanup)
and not `Merged`/`Discarded` (`WorktreeMaintenanceService` sweeps those).
- A conflicting revert is aborted immediately; no half-resolved state is left in the tree.
**`BatchMcpTools`** — best-effort loops over the `ExternalMcpService` single-entity methods.
**Sequential**, because the scoped `DbContext` is not thread-safe. Merge/review stay
single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a
failing item never aborts the rest — and rejects batches over **100 items**.
`BatchGetTasks` mirrors `ListTasks`'s `includeDescription` flag (default `false`): a found item's
`BatchGetTaskResult` carries `task` (lean) or `taskFull` (full), never both.
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
**`WaitForTaskChange(taskIds, timeoutSeconds = 60, treatWaitingForChildrenAsBusy = false)`** —
blocks until any given task leaves `Queued`/`Running`, or times out. Returns immediately for a
task already outside those two (unknown ids reported as status `"NotFound"`, also immediate).
- Implemented as an **async DB poll** (short-lived `DbContext` per check, 500 ms delay, no
held connection, no busy loop) rather than hooking `HubBroadcaster` — deliberately isolated
so it can't regress the existing broadcast callers.
- `timeoutSeconds` is clamped server-side to `TaskWaitMcpTools.MaxTimeoutSeconds` (900 s),
comfortably under the `MCP_TOOL_TIMEOUT` (930 s) every ClaudeDo-owned claude launcher sets —
`ClaudeProcess` for headless queue runs, `InteractiveLaunchSpecService` for every embedded
ConPTY session (list handler, planning, interactive resume) — so the tool reports
`timedOut: true` instead of racing the client's own abort. A caller running claude with a
different (or default: 60 s) `MCP_TOOL_TIMEOUT` will still see its own client-side timeout
fire first; the server has no way to detect or compensate for that.
- **`treatWaitingForChildrenAsBusy` pitfall (default `false`, backward-compatible):** a planning
parent with children goes `Running` → `WaitingForChildren` while children are still working,
and by default that already counts as "changed" (it's outside `Queued`/`Running`) — so waiting
on a parent returns immediately even though the unit isn't done. Set the flag to keep polling
through `WaitingForChildren`; the call then only reports changed once the parent reaches
`WaitingForReview` or a terminal status. Does not list or watch the parent's children —
callers still need their own ids for that.
- Replaced the list handler's old "sleep + poll `get_task` in a loop" Phase 3 instruction.
**`GetQueueState()`** — read-only snapshot so a caller doesn't have to infer queue state from
`maxParallelExecutions` or repeated `wait_for_task_change` rounds:
`{ configuredSlots, effectiveSlots, activeSlots: [{ slot, taskId, startedAt }], waitingTaskIds }`.
- `configuredSlots`/`effectiveSlots` reuse `QueueService.GetSlotCountsAsync` — the same
configured-vs-throttled computation `QueueService.ExecuteAsync` uses each tick (see
[usage-monitoring](usage-monitoring.md) for the throttle staging) — so this tool can't drift
from the queue's actual refill decision.
- `activeSlots` reuses `QueueService.GetActive()` (already the source for the Hub's `GetActive`):
`slot` is `"queue"` for a normal queue slot or `"override"` for the single
`run_task_now`/`continue_task` slot.
- `waitingTaskIds` is a fresh read-only query mirroring `QueuePicker.ClaimNextAsync`'s
eligibility filter and order (`Queued`, unblocked, non-manual, due, `sort_order` then
`created_at`) — it does not claim or mutate anything.
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
`Running` task.
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
helper in the same file.
**`SetMyDay`** — sets `IsMyDay` (+ optional `SortOrder`). A server-side cap-guard rejects
turning on MyDay beyond `DailyPrepMaxTasks` open (Idle) MyDay tasks.
**`GetEffectiveRunConfig`** — read-only report of what a task will *actually* run with (model,
max turns, effort, permission mode, agent path, whether a system prompt is set, skill names),
each with its source (`task`/`list`/`preset`/`global`); max turns additionally reports the raw
requested value and whether it was clamped to `AppSettings.MaxTurnsCeiling`. Unlike
`GetAppSettings`/`GetTaskConfig` (raw, possibly-unused config values), this goes through the same
`EffectiveRunConfigResolver.Resolve` that `TaskRunner` itself runs with — see
[worker-task-pipeline](./worker-task-pipeline.md)'s model/effort/max-turns section — so it can't
drift from the real run. Reads (not writes) `AppSettingsRepository.GetAsync`, which backfills
`model_presets` on first read after a null column; that backfill is pre-existing shared behavior,
not a new side effect introduced by this tool.
## Model / max-turns on task creation
Task-generating tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an
optional `model`, alias-validated via `ModelRegistry.NormalizeAlias` (`haiku`/`sonnet`/`opus`,
blank = inherit), so Claude can assign the cheapest capable model at creation time — the
planning/system/improvement prompts instruct it to do so, using
`ModelRegistry.ByCostAscending` as the cost order.
Planning's `CreateChildTask` **additionally** accepts an optional `maxTurns` (positive int;
`0`/negative rejected with `ArgumentException`, null = inherit list/global default) so the
planner can raise the turn budget for a subtask it knows will run long.
`SuggestImprovement` and `AddTask` do **not** expose it.
+252
View File
@@ -0,0 +1,252 @@
# Installer preflight: CLI version gate & environment checks
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `bdee731` (2026-08-05).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.App/ClaudeDo.App.csproj .gitea/workflows/release.yml src/ClaudeDo.Installer/Checks src/ClaudeDo.Data/Environment/ExecutableResolver.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
## Implementation status (as of 2026-08-05)
The research below (§15) led to an implementation, but it is **not on `main` yet** — it exists
on two unmerged task branches:
- `claudedo/06aca9b3afec4b939f59b627bfe21737``src/ClaudeDo.Installer/Checks/*`
(`GitCheck`, `GitIdentityCheck`, `WriteAccessCheck`, `PortCheck`, `ClaudeCliCheck`,
`ClaudeVersionCheck`, `ClaudeAuthCheck`, `PermissionModeAutoCheck`, `EnvironmentCheckService`,
`ClaudeCliLookup`) plus `SystemCheckPage` (the Fresh-Install wizard page hosting them) and its
own copy of `src/ClaudeDo.Data/Environment/ExecutableResolver.cs`.
- `claudedo/40272c0bb3b14562b59c022d09c382b6` — the original `ExecutableResolver.cs`, plus wiring
it into `ClaudeDo.Worker`'s `ClaudeCliPreflight` and `ClaudeProcess` (the actual root-cause fix:
both used to spawn `claude` with `UseShellExecute = false` and no `.cmd`/`.bat` shim resolution,
so an npm-installed `claude.cmd` was invisible to the Worker even though it worked in a shell).
The two branches were authored independently and each vendored its own copy of
`ExecutableResolver.cs` (identical except `06aca9b3` adds a `FallbackDirectories()` diagnostic
helper); merging both cleanly requires picking one copy, not literally running `git merge` twice.
Two planned follow-up tasks — a "Claude Help Me" button and a Config-mode Diagnose section —
never got past a blocked first step, precisely because this prerequisite work wasn't on `main`
when they ran. See `Environment Checks` in `src/ClaudeDo.Installer/CLAUDE.md` and `docs/open.md`
for the current gap and the manual verification checklist.
What's confirmed as **matching the research below**: `ClaudeVersionCheck.MinimumVersion` is
`2.1.220`, exactly the "verified-floor, not a proven minimum" constant from §3. `ClaudeAuthCheck`
uses `claude auth status --json` exactly as recommended in §4, parsing only the `loggedIn` field.
`PermissionModeAutoCheck` is the static "is `auto` listed in `--help`" check recommended in §2 —
real org/model/plan eligibility is deliberately **not** checked, matching the recommendation not
to build that (a real task run surfaces a startup rejection fast enough on its own). No .NET
Desktop Runtime check was implemented as an `IEnvironmentCheck` (§5's registry-key detection
remains a documented-but-unbuilt option, not currently gating anything).
---
Pure research, no code changed. Answers the five questions from the "Root Cause
`--permission-mode auto`" task. Sources: the locally installed CLI (`claude --version` /
`--help`), the official docs at `code.claude.com` (fetched 2026-08-05), and this repo's own
csproj/workflow files.
## 1. Why can `--permission-mode auto` fail?
**It is very rarely a CLI-version problem.** Per the official permission-modes doc
(`code.claude.com/docs/en/permission-modes#eliminate-prompts-with-auto-mode`), auto mode
requires **all** of:
- **Plan**: any plan (Free/Pro/Max/Team/Enterprise) qualifies in principle.
- **Organization**: on Team/Enterprise, on by default; an admin can disable it org-wide via
`permissions.disableAutoMode: "disable"` in managed settings. When disabled this way, the CLI
**"rejects `--permission-mode auto` at startup"** (verbatim from the doc) — not a runtime
fallback, a hard reject.
- **Model**: on the Anthropic API / Claude Platform on AWS — Opus 4.6+, Sonnet 4.6+, or Fable 5.
On Bedrock / Vertex / Foundry / signed-in gateway sessions — only Sonnet 5, Opus 4.7+, Fable 5.
Older models (Sonnet 4.5, Opus 4.5, Haiku, claude-3-*) are **not supported on any provider**.
A per-org `availableModels` restriction that only allows an old model would silently make
auto mode unavailable even on a Team/Enterprise org that hasn't touched `disableAutoMode`.
- **Provider opt-in (historical)**: on Bedrock/Vertex/Foundry/gateway, CLI **v2.1.158v2.1.206**
required `CLAUDE_CODE_ENABLE_AUTO_MODE=1`; **v2.1.207** removed that requirement. This does
**not** apply to a normal claude.ai / Console (Anthropic API) login — only to those four
provider types.
A separate, easy-to-hit trap: `defaultMode: "auto"` in **project or local** settings
(`.claude/settings.json`, `.claude/settings.local.json`) is silently **ignored** since v2.1.142
— it must live in `~/.claude/settings.json` (user scope). A repo that ships `defaultMode: auto`
in its own `.claude/settings.json` will start in Manual mode with **no error at all**. This
doesn't apply to ClaudeDo's case (it passes `--permission-mode auto` as an explicit CLI flag,
not via a checked-in settings file), but is worth knowing if the failure mode was "silently
starts in Manual" rather than "CLI errors out".
Quoted from the docs, verbatim: *"If Claude Code reports auto mode as unavailable, one of these
requirements is unmet; this is not a transient outage."*
**Not verifiable**: which of the above actually hit the colleague — we have no diagnostic from
their machine (no `claude auth status --json` output, no `claude --version`, no org name). Do
not guess; if this recurs, capture `claude auth status --json` and `claude --version` from the
affected machine before further debugging.
## 2. How to reliably detect whether `auto` is supported
**Static, cheap, always safe (no API call):**
- `claude --version` — semver string, e.g. `2.1.220 (Claude Code)`.
- `claude --help` — the `--permission-mode <mode>` line lists the accepted enum. Confirmed by
testing an invalid value locally:
```
$ claude -p "test" --permission-mode bogus
error: option '--permission-mode <mode>' argument 'bogus' is invalid. Allowed choices are
acceptEdits, auto, bypassPermissions, manual, dontAsk, plan.
```
This is validated by the CLI's arg parser (Commander.js) **before any network call** — exits
1 immediately. So checking that `auto` is one of the listed choices is a legitimate, free,
fast static check — but it only proves the *flag* is recognized, **not** that auto mode is
actually usable (org/model/plan gating happens later, at session start, not at arg-parse time).
- `claude auth status --json` — cheap, local/fast, **does not send a prompt**. Returns:
```json
{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
"email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" }
```
This is the answer to question 4 (see below) and also gives `subscriptionType`/`orgName` —
useful context but **still not a direct "is auto mode eligible" answer** (doesn't report the
active model or `disableAutoMode` policy).
**No cheap subcommand exists for full eligibility.** Checked `claude auto-mode --help`: it only
has `config` (effective auto-mode *rule* config — allow/deny lists, not eligibility),
`defaults` (same, shipped defaults), `critique`, and `reset`. None report plan/model/org
eligibility. `claude doctor` (non-interactive) does **not** report auto-mode eligibility either
— it explicitly says *"For a full setup checkup that can also fix issues, run `/doctor` in a
session"*; the in-session `/doctor` slash command is the one the docs say proposes
`defaultMode: auto` when eligible, but that requires an interactive session, not a scriptable
preflight.
**Dynamic (real probe) is the only way to fully confirm eligibility**, and per the docs an
org-disabled or otherwise-ineligible account **rejects at startup** (fast, before any model
turn) — so a probe doesn't have to be a full expensive run. A minimal probe such as
`claude -p "ok" --permission-mode auto --max-turns 1 --output-format json` would fail fast on
ineligibility (reject at startup) but still costs one real turn + tokens on the success path,
and still requires a working prompt/response round trip on the happy path. **Recommendation**:
don't build this into an automated preflight; a static version+flag check plus `auth status`
covers the reliably-detectable ground, and a real first task run will surface an auto-mode
rejection immediately and cheaply (fails at startup, not mid-task) if it's actually unavailable.
**Implemented as:** `PermissionModeAutoCheck` (Warning) — the static flag-listed check only.
## 3. Minimum CLI version for the flags ClaudeDo uses
Flags used (from `ClaudeArgsBuilder` per `src/ClaudeDo.Worker/CLAUDE.md`): `--permission-mode
auto`, `--effort`, `--agents`, `--json-schema`, `--append-system-prompt`, `--output-format
stream-json --verbose`, `--resume`, and (installer) `claude mcp add --transport http --scope
user`.
**Not verifiable precisely.** All eight of these are foundational, long-established flags.
The official changelog (`raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md`)
only retains roughly the last ~40 entries (oldest visible: `2.1.181`); auto mode itself, and
`--json-schema`/`--resume`/`mcp add --transport` all clearly predate that window (the earliest
found reference is a *bug fix* mentioning "sessions created before v2.1.85" for `--resume`,
implying `--resume` existed well before 2.1.85). There is no accessible source that pins an
"introduced in vX" date for any of these eight flags — guessing one would violate the task's
explicit instruction not to invent a source-less root cause.
What **is** sourced, from the docs fetched 2026-08-05:
- `--json-schema` + invalid-schema handling: before v2.1.205, an invalid schema was silently
ignored (returned unstructured text); v2.1.205 made it a hard `Error: --json-schema is not a
valid JSON Schema` exit. Not a "does it exist" gate, but changes error-handling behavior
ClaudeDo might currently rely on failing loudly.
- `--output-format stream-json --verbose` + `system/init.capabilities` array requires v2.1.205+
(absent before). Not currently consumed by ClaudeDo per the Worker CLAUDE.md's stream handling
description, so not a hard requirement today.
- `system/init.mcp_server_errors` field requires v2.1.219+. Not currently consumed by ClaudeDo.
- Manual-mode label/`manual` alias requires v2.1.200+ — irrelevant, ClaudeDo passes `auto`
explicitly, never `manual`.
- Auto mode's Bedrock/Vertex/Foundry/gateway opt-in-env-var requirement was removed in v2.1.207
— irrelevant for a direct claude.ai/Console login (this machine: `apiProvider: "firstParty"`).
**Decided constant** (see below) is therefore **the newest version we can positively confirm
works end-to-end on this machine** (`2.1.220`), not a proven theoretical minimum — because no
lower true minimum is derivable from available sources without guessing.
**Implemented as:** `ClaudeVersionCheck.MinimumVersion = new Version(2, 1, 220)` (Error).
## 4. Detecting "CLI is logged in" without sending a prompt
`claude auth status --json` (confirmed working, instant, no API/model call):
```json
{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
"email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" }
```
This is the CLI's own maintained answer — cheaper and more robust than parsing
`.credentials.json` directly (format may be internal/undocumented and is a hard-blocked file
for this task's own tooling; the docs page confirms it lives at
`%USERPROFILE%\.claude\.credentials.json` on Windows but say nothing about its schema being a
stable public contract). `claude auth status --text` is available for a human-readable variant;
`--json` is the default and the right one for a preflight to parse.
**Implemented as:** `ClaudeAuthCheck` (Error) — parses only the `loggedIn` boolean; any other
field, or a non-zero exit code, or unparseable JSON, becomes `Unknown` rather than `Failed`.
## 5. .NET runtimes required by the published `app\` / `worker\` artifacts
From `.gitea/workflows/release.yml` (the only build/publish pipeline in this repo) and the two
csproj files:
- **`ClaudeDo.App`** (`net8.0`, Avalonia, `WinExe`) — published via
`dotnet publish ... -r win-x64 --self-contained true`. **Self-contained**: bundles its own
.NET 8 runtime. **No .NET runtime needs to be pre-installed** on the target machine for the
app itself.
- **`ClaudeDo.Worker`** (`net8.0`, `Microsoft.NET.Sdk.Web`, ASP.NET Core) — same treatment:
`-r win-x64 --self-contained true`. Also fully self-contained; no ASP.NET Core runtime needs
to be pre-installed.
- **`ClaudeDo.Installer`** (`net8.0-windows`, WPF) is the one exception: published
`--self-contained false -p:PublishSingleFile=true`**framework-dependent**. The csproj
comment explains why: *"the WPF runtime pack isn't distributed for cross-compile on Linux CI,
which made self-contained bundles crash on startup with AV in the apphost."* The target
machine **must** have the **.NET 8 Desktop Runtime (x64)** installed before running
`ClaudeDo.Installer.exe` — this is the actual runtime-preflight gap, not the app/worker.
**How to check on a target machine**:
- `dotnet --list-runtimes` — look for a `Microsoft.WindowsDesktop.App 8.0.x` line (Desktop
Runtime, required by the installer). Requires the `dotnet` CLI itself to be on PATH; not
guaranteed present on a fresh machine that never installed the SDK, only the runtime — in
that case `dotnet` may not exist at all even though the runtime DLLs do.
- Registry fallback (works even without the `dotnet` CLI on PATH):
`HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App`
each installed version is a subkey/value here. This is the standard documented detection
mechanism for .NET Desktop Runtime presence on Windows and is what most installer-detection
tooling (e.g. Squirrel, WiX bundles) uses instead of shelling out to `dotnet`.
- **Not independently verified in this task**: the exact registry key shape wasn't inspected
live (would require reading `HKLM\SOFTWARE\dotnet\...` on this machine, which is standard
.NET installer-detection convention, but out of scope to screenshot/dump here since the task
is docs-only and this is a well-documented, non-project-specific Windows convention).
**Not implemented** as an `IEnvironmentCheck` — none of the shipped checks verify the Desktop
Runtime; if the Installer itself is running at all, .NET 8 Desktop Runtime is implicitly
present (framework-dependent publish would otherwise fail to launch).
## Beschlossene Konstanten
| Constant | Value | Confidence |
|---|---|---|
| Minimum CLI version | `2.1.220` | **Verified-floor, not a proven minimum.** This is the newest version confirmed installed and working end-to-end on a dev machine for every flag ClaudeDo uses. No lower true minimum could be sourced (see §3) — treat any lower value as a guess. |
| Credentials file path | `%USERPROFILE%\.claude\.credentials.json` (Windows) | Sourced from official docs; content/schema not inspected (hard-blocked secrets file). |
| Login-check command | `claude auth status --json` | Verified locally, instant, no model call. Fields: `loggedIn`, `authMethod`, `apiProvider`, `email`, `orgId`, `orgName`, `subscriptionType`. |
| App/Worker runtime requirement | **None** — self-contained win-x64 publish | Sourced from `.gitea/workflows/release.yml`. |
| Installer runtime requirement | **.NET 8 Desktop Runtime (x64)** must be pre-installed | Sourced from `.gitea/workflows/release.yml` comment + `ClaudeDo.Installer.csproj`. |
## Erkennungsstrategie pro Check
| Check | Type | Command | Expected pass output | Implemented as |
|---|---|---|---|---|
| git present + version | static | `git --version` (via `ExecutableResolver`) | resolves, exit 0 | `GitCheck` (Error) |
| git identity set | static | `git config --get user.name` / `user.email` | both non-empty | `GitIdentityCheck` (Warning) |
| install dir + data dir writable | static | probe-file write/delete | succeeds | `WriteAccessCheck` (Error) |
| SignalR/ExternalMcp ports free | static | `TcpListener` bind probe + owning-process lookup | free, or owned by running `ClaudeDo.Worker` | `PortCheck` (Warning) |
| CLI present + version | static | `claude --version` | `X.Y.Z (Claude Code)`; parse and compare `X.Y.Z >= 2.1.220` | `ClaudeCliCheck` (Error) / `ClaudeVersionCheck` (Error) |
| `auto` recognized as a flag value | static | `claude --help` (or trigger the parse error path) | `--permission-mode <mode>` help text lists `auto` among the choices | `PermissionModeAutoCheck` (Warning) |
| CLI logged in | static/cheap | `claude auth status --json` | exit 0, `loggedIn: true` | `ClaudeAuthCheck` (Error) |
| Auto mode actually eligible (org/model/plan) | **not statically detectable** | none exists | N/A — see §2; don't build this, let a real task run surface a fast startup rejection instead | not implemented (by design) |
| .NET Desktop Runtime present (installer only) | static | `dotnet --list-runtimes` (if `dotnet` on PATH) or registry `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App` | a `Microsoft.WindowsDesktop.App 8.0.x` entry exists | not implemented (see §5) |
| App/Worker runtime present | **not needed** | — | self-contained, nothing to check | not implemented (not needed) |
## Not verifiable (explicit)
- The actual root cause on the colleague's machine — no diagnostic data was captured from it.
- Exact stderr/exit-code wording the CLI prints when auto mode is rejected at startup for an
ineligible org/model (docs state the *behavior* — "rejects `--permission-mode auto` at
startup" — but not the literal message; this machine's account is eligible, so it couldn't be
reproduced locally).
- A true (not just "newest confirmed") minimum SemVer for `--effort`, `--agents`,
`--json-schema`, `--append-system-prompt`, `--resume`, `claude mcp add --transport http
--scope user` — all predate the retrievable changelog window.
- Live inspection of the `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions` registry shape on this
machine (documented Windows convention, not independently screenshotted here).
+233
View File
@@ -0,0 +1,233 @@
# Review, merge & conflict resolution
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `0d1e3b9` (2026-08-06).
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers the review→merge path: `TaskStateService` review transitions, `TaskMergeService`,
`PlanningMergeOrchestrator`, the post-merge verify gate, and the UI conflict resolver.
## Approve = merge the whole unit
`ApproveReview` (hub) and `review_task` approve (MCP) are the **single** review+merge action.
There is no separate "Merge all" entry.
- **Task with children** → drives `PlanningMergeOrchestrator`: merges the parent worktree if
`Active`, then each `Done` child in order, then sets the parent `Done`. A mid-merge conflict
pauses for `ContinuePlanningMerge` / `AbortPlanningMerge`.
- **Childless task** → `TaskMergeService.ApproveAndMergeAsync`. A conflict keeps the task in
`WaitingForReview`.
- **No active worktree** (sandbox run) → straight to `Done`.
Review transitions all live in `TaskStateService`: `SubmitForReviewAsync`,
`SubmitForChildrenAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`,
`ClearReviewFeedbackAsync`.
`ReviewFeedback` (nullable string on `TaskEntity`) is the reviewer's rejection comment: set by
`RejectToQueueAsync`, consumed and cleared by `QueueService` on the next re-run, where it
becomes the next-turn prompt of the resumed Claude session.
## Unified parent model
Every parent — planning **or** improvement — flows
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
`TaskStateService.TryAdvanceParentAsync`. It surfaces any `WaitingForChildren` parent for
review once all children are terminal; failed/cancelled children are **annotated on the
result, not wedged**.
- A planning parent enters `WaitingForChildren` at `FinalizePlanningAsync` (or
`WaitingForReview` directly if it has no children).
- An improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned children.
- Planning/improvement **children** go straight to `Done` — no individual review. Only the
parent is reviewed.
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not**
advance the parent — the parent stays in `WaitingForChildren` until every child is terminal.
The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children
need attention" band) so the roadblock is visible without forcing a transition.
A blocked planning/improvement child still goes straight to `Done` per the unified parent model
above — it committed nothing, but nothing prevents its (empty) branch from being unit-merged
like any other `Done` child once the parent is approved. The MCP surface has no UI equivalent of
`ChildOutcomes`, so `review_task`'s approve on a parent additionally returns `emptyChildren` (the
`Done` children whose review range is empty) and every task-returning tool exposes
`TaskRefDto.roadblockCount` → [external-mcp.md](external-mcp.md) → `ReviewTask`/`PreviewMerge`.
An empty branch is still mergeable by design (some tasks — e.g. an audit — legitimately produce
no diff); this is a visibility fix, not a merge gate.
## Cancel is blocked while a unit merge is draining
`ApproveReview` on a task with children awaits `PlanningMergeOrchestrator.StartAsync` /
`DrainAsync` synchronously — the parent sits in `WaitingForReview` for the whole (potentially
minutes-long, one-child-at-a-time) drain. Without a guard, a concurrent `CancelReview` (UI or
`update_task_status`/`cancel_task` via MCP) could flip the parent to `Cancelled` mid-drain while
the orchestrator kept merging children's worktrees onto the target branch; `FinalizeParentDoneAsync`
then finds the parent no longer `WaitingForReview` and gives up, leaving the merged children's
diffs stranded with no rollback.
`TaskStateService.CancelAsync` now rejects with a `TransitionResult` reason whenever
`PlanningMergeOrchestrator.HasActiveMerge(taskId)` is true for the task being cancelled, before any
DB write. Cycle note: `TaskStateService` can't take a direct constructor dependency on
`PlanningMergeOrchestrator` (which itself depends on `ITaskStateService`), so it takes a lazily-resolved
`Func<IActiveMergeState>` instead — same cycle-breaking shape as the existing `Func<ITaskStateService>`
handed to `PlanningChainCoordinator`. `IActiveMergeState` (`Planning/Interfaces/`) is `PlanningMergeOrchestrator`'s
only public surface `TaskStateService` needs.
The UI mirrors this as polish: `DetailsIslandViewModel.IsMergeDraining` (set on
`PlanningMergeStartedEvent`, cleared on `PlanningMergeAborted`/`PlanningCompleted` for the bound
task) gates `CancelReviewCommand`'s `CanExecute` — same shape as `WorktreesOverviewModalViewModel.IsMerging`
gating `CanMergeAll`. The worker-side guard remains the actual correctness fix; the button gate
just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently.
## Post-merge verify gate
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
Null/blank (the default) = **no gate**, behavior bit-identical to before the feature existed.
When set, `TaskMergeService` runs it via `VerifyCommandRunner` (`cmd.exe /c <command>`,
10-minute fixed timeout, output tail-captured) in `list.WorkingDir` right after a successful
`MergeNoFfAsync` / `ContinueMergeAsync` **and** worktree cleanup, but **before** the task is
allowed to reach `Done`.
| Outcome | Effect |
|---|---|
| Exit 0 | Unchanged flow — worktree `Merged`, task `Done` if it was `WaitingForReview`. |
| Non-zero exit or timeout | The git merge is **deliberately left in place** (no auto-revert — that's a separate, unbuilt feature). The worktree is still marked `Merged` (it's already gone from disk when `removeWorktree` was requested), but the task stays out of `Done`. |
On failure `MergeResult.Status` comes back `TaskMergeService.StatusVerifyFailed`
(`"verify_failed"`) with an output excerpt in `ErrorMessage`. This flows through
`MergeResultDto` (hub) and `ReviewTaskResult` (`review_task`) unchanged, because both already
treat any non-`blocked`/`conflict` status generically. All three UI merge entry points handle it
explicitly (detail-pane Approve, merge modal, worktrees batch) — a generic fallback there showed
the raw status string instead of the failure.
**Worktree-less approvals are gated too.** A task with no active `WorktreeEntity` — a sandbox run,
or a list-handler task that commits straight into `list.WorkingDir` — skips the merge entirely,
but `ApproveAndMergeAsync` still runs the verify command (same per-repo gate, working dir =
`list.WorkingDir`) before the task may reach `Done`. Without that, the run that lands the most on
the target branch at once would be the one run nothing checks.
**Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
`list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo,
so a verify run can't be interrupted by a second merge landing in the same working dir
mid-build.
## `MergeCommit` and revert
`WorktreeEntity.MergeCommit` (nullable) is the SHA of the merge commit this worktree's branch
produced on the target branch. Stamped by `TaskMergeService` the moment a merge/continue-merge
succeeds, written **only** by `WorktreeRepository.SetMergedAsync` (which atomically sets
`State=Merged` and stamps the SHA in one update).
It is the only thing that makes `revert_merge` possible without heuristically searching
`git log` — see [external-mcp.md](external-mcp.md) → `RevertMerge`. Null for any worktree
merged before the field existed.
## Review gate in the UI
**Approve & Merge is gated behind opening the diff.** When there is something to inspect
(worktree diff / merged range / children combined diff), the button stays disabled until the
diff or combined-diff viewer has been opened once. The gate **re-locks per run** — any state
change resets it. Tasks with nothing to inspect are never gated.
The row-level quick-approve in the task list is an **intentional bypass**.
Implementation: `MergeSectionViewModel` owns merge-target selection, the mergeability
indicator (`MergePreviewPresenter` over `PreviewMergeAsync`), and `OpenDiffAsync` /
`ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and
fire the `DiffViewed` callback. `HasReviewableDiff` reports whether anything is inspectable
and feeds the gate.
## Conflict resolver (in-app Rider-style 3-pane merge editor)
`ConflictResolverViewModel` + `Views/Conflicts/ConflictResolverView`. Handles **both**
single-task and planning unit-merge conflicts.
### Model
Single-task mode starts the conflict merge, then parses each conflicted file into stable and
conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`. Types live in
`ConflictModels`: `MergeFile` / `MergeFileSegment` / `MergeConflictBlock`.
Exposed per active file: `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText`
(reconstructed from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved
conflicts with Ours), plus `ActiveFile` / `SelectFileCommand` (multi-file switcher),
`Current` / `Next` / `Previous` (focused-conflict nav), a per-file `PositionText` readout,
per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on
**every file resolved + no binary**. Each file is written via `WriteConflictResolution`.
**Planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's
mid-merge conflicts **without re-starting the merge** and routes continue/abort to
`ContinuePlanningMerge` / `AbortPlanningMerge`, so a unit-merge conflict re-opens the editor
per subtask via the `PlanningMergeConflict` broadcast.
**Except when an MCP session is driving the merge.** `PlanningMergeOrchestrator.StartAsync` takes
an `externallyDriven` bool (default `false`; `ExternalMcpService.review_task`'s parent-with-children
path passes `true`, since a running Claude session — not the UI — will resolve conflicts via
`continue_merge`/`abort_merge`). The flag rides along on the `PlanningMergeConflict` broadcast
(4th arg); `IslandsShellViewModel.OnPlanningMergeConflict` only auto-opens the resolver when it's
`false` — otherwise it shows a persistent, non-auto-dismissing banner
(`IsExternalMergeBannerVisible`) with a manual "Open resolver" button, cleared on
`PlanningMergeAborted`/`PlanningCompleted`. This exists because two parties (a human and the
driving session) could otherwise end up editing the same shared checkout at once.
`PlanningMergeOrchestrator.GetActiveExternalConflictsAsync` (hub: `GetActiveExternalPlanningMergeConflicts`)
re-derives this state by checking `GitService.IsMidMergeAsync` rather than trusting the in-memory
flag alone, so a UI restart mid-merge (or a stale entry left behind if something resolved the
repo outside the normal Continue/Abort path) can't show a phantom banner — the Ui calls it on
`ConnectionRestoredEvent`. The childless single-task conflict path
(`TaskMergeService.ApproveAndMergeAsync`) has no equivalent broadcast — it only sends the generic
`TaskUpdated` — so it never auto-opened the resolver and needed no change.
### View
Three **AvaloniaEdit** panes showing the whole file: MAIN/ours (read-only) | editable Result |
INCOMING/theirs (read-only). TextMate highlighting by extension (theme `StyleInclude` in
`App.axaml`).
- A code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across
panes. Tints live in `Tokens.axaml` (`Merge*TintBrush`).
- An `IReadOnlySectionProvider` + `TextAnchor` regions keep **only conflict spans** editable in
Result; edits flow back to the block.
- Each unresolved conflict starts **EMPTY** (a thin marker bar).
- The between-pane gutter controls **toggle** each side in/out of the result: ``/`` add
MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a
conflict can take main, incoming, both, or **neither**.
- `FilesSummary` shows how many files still have conflicts. The three panes share a
proportional synced vertical scroll.
- A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the
file proportionally; click a tick to jump. Useful for long files.
### Entry points
Review **Approve** on conflict, and the **Merge** button in the Diff window (a conflicting
`MergeTask` hands off via `RequestConflictResolution`).
## Hub methods
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto`,
`ContinuePlanningMerge` / `AbortPlanningMerge`, `PreviewMerge(taskId, targetBranch) ->
MergePreviewDto`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`,
`GetMergeTargets`
- Single-task conflict resolver: `StartConflictMerge`, `GetMergeConflictDocuments`,
`WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` — note the
service-level `TaskMergeService.ContinueMergeAsync` / `AbortMergeAsync` keep their own names.
- Broadcast events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`,
`PlanningMergeAborted`, `PlanningCompleted`
## Diff stack (UI)
`UnifiedDiffParser` (static) parses `git diff` output into `DiffFileViewModel`s, detecting
added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for
a combined single-pane view. `DiffModels.cs` holds the shared types (`DiffLineViewModel`,
`DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
`DiffTreeNodeViewModel`, `DiffTree`).
`DiffViewerViewModel` is one unified read-only viewer with two modes:
- **Files** — dirty worktree / branch-vs-base / commit-range. Loads via `GitService`, folder
file-tree left + per-file diff pane right, Merge button for a live branch source.
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat
diff right, combined integration-branch toggle.
`DiffLinesView` renders per-file content with binary/empty placeholders.
+142
View File
@@ -0,0 +1,142 @@
# Usage monitoring, gate & throttle
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `f6cb825` (2026-08-05).
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/Usage/`, the queue's throttle/gate integration, per-run token
accounting, and the UI surfaces (usage pill + usage monitor modal).
## Data source
`GET https://api.anthropic.com/api/oauth/usage` — an **undocumented** Anthropic endpoint,
authenticated with the Bearer access token Claude Code itself keeps fresh at
`~/.claude/.credentials.json`. ClaudeDo reads that token, never refreshes it, never logs it.
Because the endpoint is undocumented and can change without notice, **every consumer
fails open**. That is the single most important invariant here.
## Components (`Usage/`)
| Type | Role |
|---|---|
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0100 percent — compare directly against thresholds, don't rescale. |
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
| `TranscriptUsageReader` | Aggregates token usage from Claude Code transcripts. |
Interfaces in `Usage/Interfaces/`: `IUsageClient`, `ITranscriptUsageReader`, `IUsageGate`.
## The gate (hard pause)
Thresholds: `AppSettings.UsageGateFiveHourPct` / `UsageGateSevenDayPct` (defaults 80/90).
Blocked once `five_hour >= UsageGateFiveHourPct` **or** `seven_day >= UsageGateSevenDayPct`
(`>=`, not `>`). Threshold `0` = that bucket never gates.
What it pauses: **only the queue's slot-fill loop** — new queued tasks don't start.
Unaffected: already-running runs, `RunNow`, `ContinueTask`, interactive ConPTY sessions,
planning sessions, daily prep (all bypass the queue).
**Fail-open**: no snapshot yet, a failed last poll, or an app-settings read error all
resolve to not-blocked.
There is **no persistent pause state**. Recovery is just the queue's 30 s backstop timer
(`queue_backstop_interval_ms`) re-evaluating the gate on its own once usage drops back
under the threshold. A blocked↔free transition is logged and broadcast (`WorkerLog`, Warn
on block / Info on resume) exactly **once per change**, not every tick.
## The throttle (staged parallelism)
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct,
gateFiveHourPct, gateSevenDayPct)` — pure static, no state.
Thresholds `usage_throttle_soft_pct` / `usage_throttle_hard_pct` (defaults 50/65).
Whichever of 5h/7d is **more utilized** decides the stage:
| Utilization | Effective slots |
|---|---|
| below soft | full configured `max_parallel_executions` |
| `>= softPct` | capped at 2 |
| `>= hardPct` | capped at 1 |
| `>=` either gate threshold | 0 — same hard block as `UsageGate` |
A threshold of `0` disables that stage. The `0` return is deliberately kept in sync with
`UsageGate`'s hard block because both read the same gate thresholds — change one, change both.
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
runs to completion. Same fail-open policy: no snapshot means no throttling.
The effective stage (configured vs. effective slots + the decisive bucket, `ThrottleBucket`
= `"five_hour"` / `"seven_day"`) rides along on `UsageSnapshotDto` purely for UI display.
It does **not** change what the gate gates on.
## Queue integration (`Queue/QueueService`)
Per loop tick:
1. `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it
down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot.
A missing/failed snapshot fails open to the configured value. A **stage change** (not
every tick) logs once.
2. Separately, `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped
entirely for that tick.
## Per-run token accounting
`task_runs` stores four raw token fields: `tokens_in` / `tokens_out` /
`cache_read_tokens` / `cache_write_tokens`.
These are **not** read from the stream-json `result` event's `usage.input_tokens` — that is
only the uncached remainder of a single API call and undercounts the real prompt size by
orders of magnitude once caching kicks in.
Instead `TaskRunner.ApplyUsageAsync` calls
`ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` — the session transcript's
cumulative raw totals across every assistant message, located by `{sessionId}.jsonl` — and
stores the **delta** against prior `task_runs` rows sharing the same `session_id`, so a
`--resume`'d run doesn't double-count turns already billed to an earlier run.
A missing/unreadable transcript leaves all four fields `null`; it never fails the run.
### `TranscriptUsageReader` details
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
Other), deduped by `requestId`, with a per-file length+mtime cache.
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
## UI surfaces
- **`UsagePillViewModel`** — one shared instance backs the `UsagePill` control in both the
footer and the Mission Control header. Loads via `GetUsageSnapshotAsync`, updates live off
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
`null` on plans that don't have them; a fixed gauge layout would break. Also shows model
usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage
(`GetTaskUsageAsync`) over a 7d/30d preset or custom range.
## Hub surface
- `GetUsageSnapshot() -> UsageSnapshotDto` — percentages/limits/`FetchedAtUtc` are null and
`IsStale=true` when no snapshot has landed yet. `IsStale` also trips on a failed last poll
or a snapshot older than 3× `usage_poll_interval_seconds`.
- `GetModelUsage(from, to)` — thin wrapper over `ITranscriptUsageReader.ReadAsync`.
- `GetTaskUsage(from, to)` — top consumers from `task_runs` joined to task/list, grouped per
task. Null token columns count as **0**, never drop the row. `Model` comes from that task's
most recent run. Sorted by total tokens descending, capped at 100.
- `UsageUpdated` event carries the same `UsageSnapshotDto`.
## Settings columns
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
`usage_throttle_soft_pct` / `usage_throttle_hard_pct` (50/65). All four clamped 0..100 by
`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`.
+57 -4
View File
@@ -1,6 +1,6 @@
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `896d4b5` (2026-07-23).
> Drift check: `git log --oneline 896d4b5..HEAD -- src/ClaudeDo.Worker`
> Last verified against commit `cc90600` (2026-08-06).
> Drift check: `git log --oneline cc90600..HEAD -- src/ClaudeDo.Worker`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
# Worker: Task Execution Pipeline
@@ -28,9 +28,11 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
5. **Run Preparation**`TaskRunner.RunAsync()` (Runner/TaskRunner.cs)
- Loads task, list config, subtasks, attachments from the DB.
- `StartRunningAsync()` (only if not pre-claimed): atomic claim to Running, **before any
resource is created**. A rejected claim (task already Running) bails out immediately —
no worktree, no MCP token file. Broadcasts TaskStarted.
- `PrepareRunDirectoryAsync()`: worktree (via WorktreeManager) if the list has a WorkingDir,
else sandbox. Generates a per-run MCP token, writes MCP config to disk.
- `StartRunningAsync()` (only if not pre-claimed): atomic Queued → Running. Broadcasts TaskStarted.
6. **Claude Execution**`TaskRunner.RunOnceAsync()` (Runner/TaskRunner.cs)
- Creates a TaskRunEntity, points the task at the run's log path.
@@ -50,9 +52,47 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
- **Done** CompleteAsync (Running → Done) — top-level success.
- **WaitingForReview** SubmitForReviewAsync (Running → WaitingForReview) — review gate.
- **WaitingForChildren** SubmitForChildrenAsync (Running → WaitingForChildren) — blocks on children.
Advances to WaitingForReview via `TryAdvanceParentAsync` once every remaining child is
terminal (Done/Failed/Cancelled) — including zero children left, e.g. after the last child
is deleted (`WorkerHub.DeleteTask` / `ExternalMcpService.DeleteTask` both call it).
- **Failed** FailAsync (Running/Queued → Failed).
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
## Model, effort & max-turns resolution
*(section added at commit `f6cb825`, 2026-08-05; resolver extraction added same day)*
The resolution below lives in `Runner/EffectiveRunConfigResolver.Resolve` (not inlined in
`TaskRunner` anymore) so `TaskRunner.ResolveConfigAsync` and the read-only
`get_effective_run_config` MCP tool (`External/ConfigMcpTools.cs`) share one codepath and can't
report different numbers for the same task. The tool additionally surfaces, per field, whether
it came from the task/list/preset/global layer, and — for max turns — the raw requested value
plus whether it was clamped.
Step 6 builds the CLI args. Model and turn budget resolve like this:
1. **Effective model** — task override → list config → `AppSettings.DefaultModel`.
2. **Preset row**`ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns)`.
The model string is resolved through `ModelRegistry.TryNormalizeAlias` **first**, so a full
CLI model id (e.g. `claude-sonnet-4-6`, not just the bare `sonnet`/`opus`/`haiku`/`fable`
aliases) still hits its alias's preset row instead of missing every lookup. Only a model
that normalizes to nothing recognized falls back to a synthesized row using
`AppSettings.DefaultMaxTurns`**never a hardcoded number, and it never throws**: an
unknown model must not block a run.
3. The preset supplies `--effort` and the **global** max-turns default. Task/list `MaxTurns`
overrides still win over it.
4. **Ceiling clamp**`TaskRunner.ResolveMaxTurns` hard-clamps the resolved value to
`AppSettings.MaxTurnsCeiling` (default 80). An override above the ceiling still starts, just
capped, and a Warn logs the task id + requested + effective value.
⚠️ **Trap:** if `app_settings.model_presets` is somehow null, the fallback path decides the turn
budget — which is why `AppSettingsRepository.GetAsync` backfills shipping defaults on the first
read after null. Ship preset turns are low (haiku 20, sonnet 30, opus 40, fable 25), so a task
that genuinely needs a long run must set its own `MaxTurns`.
Prompt composition: `TaskPromptComposer.Compose` injects attachment **absolute paths** as a
read-only "## Reference files" section.
## Component Responsibilities
**Queue/**
@@ -73,7 +113,11 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
**Lifecycle/** (startup recovery)
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed.
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed. The underlying
`TaskStateService.RecoverStaleRunningAsync` bulk-flips Running→Failed, then re-runs the same
chain/parent-advance side effects as a normal `FailAsync` (per recovered id, best-effort) so a
crash mid-chain-child or mid-improvement-child doesn't leave a successor blocked forever or a
`WaitingForChildren` parent wedged.
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
- `TaskResetService` — manual reset to Idle.
@@ -113,6 +157,15 @@ Program.cs (DI setup)
- **Slot limit** — respects MaxParallelExecutions; a backstop timer wakes even if a Wake() is missed.
- **Pre-claimed tasks** — the dispatcher pre-claims via the picker; the override slot
(RunNow/ContinueTask) must call StartRunningAsync if a task is not pre-claimed.
- **Claim before create** — `TaskRunner.RunAsync`'s unclaimed path calls `StartRunningAsync`
*before* `PrepareRunDirectoryAsync`. RunNow racing the picker for the same Queued row used to
create the worktree first and only claim afterwards, so the losing dispatch could hit
WorktreeManager's branch-collision self-heal and force-remove the winner's live worktree
mid-run. `OverrideSlotService.RunNow` also fast-rejects a task already Running in the DB
(defense in depth; the picker's atomic SQL claim is the real arbiter either way).
`RunCancellationRegistry.Register` refuses (and logs) a second registration for the same task
id instead of silently overwriting the first, so a losing dispatch's cleanup can't unregister
the winner's CTS out from under it.
- **Terminal writes** — use `CancellationToken.None`; a task is never left Running after crash/cancel.
- **Per-run MCP tokens** — each run gets a unique token scoping tool access; unregistered on end.
- **Auto-retry** — one automatic retry if a session exists and the first run failed.
+173 -60
View File
@@ -1,53 +1,38 @@
# ClaudeDo — Offene Punkte
Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus der Verifikations-Session vom 2026-07-24. Die laufende Verifikations-Checkliste (Pass/Fail je Abschnitt, inkl. noch offener Abschnitte §7–§11 und Kanten) lebt in `docs/verification-handoff.md`. Erledigtes steht in den Commits/im Code, nicht hier.
Stand: 2026-08-06. Der Findings-Block von 2026-07-24 wurde am 2026-08-06 **gegen den Code
nachverifiziert** (statisch, nicht in der laufenden App); alles, was inzwischen gefixt ist, ist
hier entfernt — Erledigtes steht in den Commits/im Code, nicht hier. Die alte Verifikations-
Checkliste lebt in `docs/verification-handoff.md`.
---
## Bugs (verifiziert 2026-07-24)
## Bugs (offen)
- **OUTCOME-Karte rendert rohes Structured-Output-JSON:** `TaskMonitorViewModel.ApplyOutcome` (ClaudeDo.Ui) setzt bei Tasks ohne Roadblock-Marker `SessionOutcome = result` wörtlich (Zeile ~261). Der Worker legt in `task.Result` das rohe `{"summary":…,"files_changed":[…]}` ab (die lesbare Fassung steht in `task_runs.resultMarkdown`), also zeigt die Detail-Insel OUTCOME als JSON-Blob statt als Text. Fix: (a) UI parst ein JSON-Result und zeigt `summary`, oder (b) Worker schreibt `summary`/`resultMarkdown` statt des JSON in `task.Result`. Verifiziert am Task `verif §1 diff matrix`. Deckt sich mit Memory `worker_testing_findings`.
- **Approve & Merge schluckt einen „blocked"-Merge still:** `DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `result.Status == "conflict"` (öffnet den Resolver); bei `"blocked"` (z.B. Ziel-Working-Tree hat uncommittete getrackte Änderungen) und anderen Nicht-`merged`/Nicht-`conflict`-Status passiert **nichts** — kein Footer-Fehler, kein Dialog (der `catch` greift nur bei Exceptions, „blocked" ist aber ein normaler Rückgabewert mit `ErrorMessage`). User sieht „Klick tut nichts". Doppelt verifiziert (Konflikt-Approve UND sauberer additiver Approve `verif §1b`, beide bei dirty `main`-Checkout still). Fix: bei `blocked`/unerwartetem Status `result.ErrorMessage` via `ShowErrorAsync`/`FlashFooterError` surfacen. Verstößt gegen `feedback_ui_error_surfacing`. (Der Konflikt-Resolver + 3-Pane-Editor funktionieren, sobald der Ziel-Tree sauber ist.)
- **„Waiting for Improvements" für Planning-Parents (Terminologie):** `taskStatus.waitingForChildren` = „Waiting for Improvements" (en.json:504), `agentStatus.children` dito (503), `childOutcomesLabel` = „IMPROVEMENTS" (191). Seit dem unified-parent-Modell gilt `WaitingForChildren` für Planning **und** Improvement-Parents — „Improvements" ist für einen Planning-Parent falsch. Auf neutrales „Waiting for Subtasks"/„Subtasks" umstellen (en **und** de — Localization.Tests-Parität). Verifiziert §3.
- **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live:** Parent-getriebene Änderungen propagieren nicht auf die Kind-`TaskRowViewModel`s ohne Listen-Reload. (a) Nach „Finalize planning" bleiben Kind-Rows auf „Draft" (`IsDraft`) statt „Planned" (`IsPlanned`) — der geänderte `ParentFinalized` kommt nicht über das Parent-`TaskUpdated`-Broadcast an. (b) Nach „Discard" bleiben die (in der DB gelöschten) Draft-Kind-Rows sichtbar, bis die Liste neu geladen wird. Gemeinsame Ursache: die Kinderliste/-zustände werden bei Parent-Transitionen nicht live neu aufgelöst. Doppelt verifiziert §3 (Finalize **und** Discard, 2026-07-24).
- **AskUser-Frage erscheint NUR in Mission Control, nicht in der Task-Detail-Insel:** Der `ask_user`-Banner (Frage + Antwort-Eingabe) lebt ausschließlich in `MonitorPaneView` (Mission Control). Die Detail-Insel (`DetailsIslandView`) streamt zwar den Live-Log eines laufenden Tasks, zeigt aber **kein** Frage-Banner — ein Nutzer, der nur die Detailansicht offen hat, sieht nicht, dass der Run auf eine Antwort wartet, und läuft nach 3 min in den Timeout-Fallback. Verifiziert §7 (User schaute Detailansicht, Frage war unsichtbar; erst in Mission Control sichtbar). Fix: Frage-Banner + Inline-Antwort auch in der Detail-Insel für den gebundenen laufenden Task surfacen (VM-Zustand liegt bereits in `TaskMonitorViewModel`, müsste für die Detail-Insel repliziert/geteilt werden). **Von Mika bei der §7-Verifikation ausdrücklich gewünscht** („vermisse diese Interaktion in der Detail-Ansicht"). §7.
- **„New session"-Button (Mission Control) unsichtbar — Icon.Plus ist Strich-Only:** `Icon.Plus` = `M12 5v14M5 12h14` (IslandStyles.axaml:61) ist reine Linien-Geometrie ohne Fläche; im `<PathIcon>` (MissionControlView.axaml:38, füllt Geometrie) rendert sie **unsichtbar** → der Ad-hoc-„New session"-Button erscheint leer und ist nicht auffindbar (Feature + VM `OpenAdHocConPtySessionAsync` + Tests funktionieren, nur das Icon fehlt sichtbar). Fix: Icon.Plus als gefüllte Geometrie authoren ODER als gestricheltes `Path` rendern (Icon-Gotcha in CLAUDE.md). **Andere `Icon.Plus`-Verwendungen mitprüfen.** Verifiziert §5.
- **Verwaiste git-Worktrees sind für die App unsichtbar** (gefunden 2026-08-06). In `C:\Private\ClaudeDo` waren nach dem Cleanup 22 git-registrierte Worktrees + 26 `claudedo/*`-Branches vorhanden, die ClaudeDo-DB kannte davon nur zwei. Die Worktrees-Übersicht listet ausschließlich Zeilen aus `worktrees`, also kann der Nutzer diese Reste nicht über die App entfernen — und es gibt keinen Sweep dafür (`OrphanRecovery` räumt nur Task-Zeilen auf, keine Worktrees). Wunsch: entweder ein Startup-Abgleich `git worktree list` ↔ DB, der Unbekannte als „untracked" in die Übersicht aufnimmt, oder mindestens eine Warnung mit Anzahl.
## UX / Nits (verifiziert 2026-07-24)
## UX / Nits (offen)
- **„Resume planning session" ist grundsätzlich kaputt (Session-Id wird nie erfasst) + Fehler wird verschluckt:** Im UnfinishedPlanning-Modal macht **Resume** nichts. Ursache: `PlanningSessionManager.ResumeAsync` (PlanningSessionManager.cs:238) bricht mit `InvalidOperationException("No Claude session ID captured yet; cannot resume.")` ab, wenn `task.PlanningSessionId` leer ist — und der Setter `TaskRepository.UpdatePlanningSessionIdAsync` (TaskRepository.cs:322) hat **keinen einzigen Aufrufer** im Worker, d.h. `planning_session_id` wird nie befüllt (verifiziert an `verif §3 resume-discard`: `planning_session_id=NULL`, Token gesetzt, Drafts angelegt). Resume kann also **nie** erfolgreich sein. Zusätzlich verschluckt `TasksIslandViewModel.ResumePlanningSessionAsync` den ganzen Block in einem leeren `catch { }` (~Zeile 870) → der Button ist ein **stummer No-op**, kein Footer-Fehler, kein Dialog (verstößt gegen `feedback_ui_error_surfacing`). Hintergrund: die wt-Planning-Session ist interaktiv; ClaudeDo erfasst die claude-Session-Id dort nicht (analog zur bewusst nicht persistierten ConPTY-Session-Id, §5). Fix-Optionen: (a) beim Planning-Start/-Lauf die claude-Session-Id erfassen und via `UpdatePlanningSessionIdAsync` persistieren, damit Resume echt resumen kann; ODER (b) Resume entfernen/deaktivieren, wenn keine Session-Id vorliegt; in JEDEM Fall den Fehler statt des leeren `catch` surfacen. Verifiziert §3. §3.
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal (App-Neustart nötig, um die „erster-Drop"-Bedingung wiederherzustellen). Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen (die genaue Exception-Message fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt). Verifiziert §9 (einmalig beobachtet). §9.
- **Agent-Settings-Gear weicht vom übrigen Settings-Icon ab:** Der Agent-Settings-Flyout-Button (`TaskHeaderBar.axaml:68`) rendert ein Unicode-Glyph `⚙` (`<TextBlock Text="⚙">`), während die Listen-Nav (`ListsIslandView.axaml:56`) und die Listen-Settings (`TasksIslandView.axaml:47`) das gefüllte `Icon.Settings`-PathIcon (Gear-StreamGeometry, IslandStyles.axaml:110) nutzen → optisch ein anderes Zahnrad. Angleichen: den `⚙`-TextBlock durch `<PathIcon Data="{StaticResource Icon.Settings}" .../>` ersetzen. Verifiziert §8 (User-Sichtprüfung). §8.
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Verifiziert §8 (frischer Zustand vor ponytail-Install). Kleiner Empty-State-Text ergänzen.
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber `TaskRowViewModel.StatusLabel` (Zeile 132) kennt nur `HasInteractiveSession`/`IsParked` als Overrides — der `PlanningBadge` ersetzt den Chip nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".)
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt Reihenfolge/Abhängigkeit nicht dar — es gibt nur den „waiting"-Chip. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>").
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks` (TaskRowViewModel.cs:99), `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind.
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Im `sessionSkillsTab`-Locale-Namespace (en.json:674) gibt es keinen `empty`-Key.
- **Modal-Bodies sind unten abgeschnitten** (Sichtprüfung 2026-08-06, beobachtet in den Listen-Settings: der Hinweistext unter „Verify command" ist mitten in der Zeile vom Footer weggeschnitten und lässt sich nicht herunterscrollen; laut Mika „fast überall" in den Settings-Modals). Gemeinsames Muster: `<ScrollViewer Padding="20,16">` als Modal-Body — in `ListSettingsModalView:36`, `MergeModalView:33`, `WorktreesOverviewModalView:131`, `AboutModalView:20`, `MergeHelperSelectionModal:47`, `RepoImportModalView:45`, `LogVisualizerView:45` sowie `WorkConsole:295/395`. **Vermutete** Ursache (nicht verifiziert): das untere `Padding` des ScrollViewers zählt nicht zum scrollbaren Extent, die letzten Pixel sind also unerreichbar. Erst am echten Fall nachmessen, dann ggf. einheitlich das Padding vom ScrollViewer auf ein `Margin` des inneren Inhalts umziehen.
- **Usage-Monitor-Modal: Analyse-Tabellen ausbaufähig** (Sichtprüfung 2026-08-06; Gauges, Info-Bänder, Presets/Custom-Range und Refresh-Button sind in Ordnung). Models-Tab: die Spaltenköpfe „OTHER CACHE" und „SHARE" kollidieren zu `OTHER CACHISHARE`; `claude-haiku-4-5-20251001` läuft in die IN-Spalte; alle Zahlen linksbündig und ohne Tausendertrenner (`506830708`). Tasks-Tab: Task-Titel wird ohne Ellipse hart an der LIST-Spalte abgeschnitten, der LIST-Text ebenfalls; MODEL ist bei Runs von vor der `task_runs.model`-Spalte leer (besser „—"). Wunsch: Zahlen rechtsbündig + `#,##0` (oder k/M), Spaltenbreiten/Truncation fixen.
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien".
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal. Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen.
- **Diff-Viewer: reiner Rename schwach dargestellt:** Rename korrekt erkannt (`UnifiedDiffParser``DiffFileStatus.Renamed`, Badge `StatusCode="R"`), aber der File-Tree zeigt nur den neuen Namen + „+0 0" ohne „alt → neu"-Pfad; liest sich wie „keine Änderung". Optional: alten Pfad + „renamed"-Label anzeigen. §1.
- **Header-TurnsText zeigt `0/max` für abgeschlossene Runs:** `TurnsText => {Turns}/{EffectiveMaxTurns}``Turns` wird beim Laden eines terminalen Tasks nicht aus `task_runs.turnCount` restauriert (nur live gefüllt). Kosmetisch. §1.
- **Conflict-Resolver: Continue/Merge-Button klickbar trotz offener Konflikte:** Gate greift funktional (merged erst wenn alle Konflikte in allen Dateien gelöst), aber der Button ist nicht disabled/gegraut → früher Klick ist ein stummer No-op. Besser: disabled bis `AllResolved`, oder Hinweis „N Konflikte in M Dateien offen". §4.
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien". §4.
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Status-Chip zeigt „Idle"; der `PlanningBadge` ersetzt das nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".) §3.
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt die Reihenfolge/Abhängigkeit nicht dar. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>"), nicht nur der „waiting"-Chip nach dem Queueen. §3.
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks`, `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind. §3.
- **„Open ConPTY session" erneut = Prompt wird neu gesendet, kein Resume:** Da die ConPTY-Session-Id bewusst nicht persistiert wird, startet ein erneutes „Open ConPTY session" auf demselben Task eine frische Session und sendet den Task-Prompt erneut (re-runt die Arbeit im selben Worktree) statt zu resumen. So designt, aber UX-Falle — evtl. „Resume"-Affordance oder Re-Open-Warnung. §5.
## Feature-Wünsche
## Feature-Wünsche (aus der Session)
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen).
- **Die Handler-Session soll „Submit for review" selbst auslösen können** (Sichtprüfung 2026-08-06). Aktuell ist der Abschluss eines List-Handler-Laufs ein reiner Handgriff über die Schaltfläche in der Mission-Control-Kachel — die Session selbst hat kein Werkzeug dafür, obwohl sie am besten weiß, wann sie fertig ist. Wunsch: ein MCP-Tool auf der In-Task-Oberfläche, das denselben Pfad wie der Button nimmt.
- **Handoff-Kachel besser beschriften** (Sichtprüfung 2026-08-06). Die zweite Kachel heißt `Merge Helper — <Liste> (Handoff)` bzw. „(Übergabe)" (`missionControl.mergeHelperHandoffTitleSuffix`, en.json/de.json:297). „Handoff" ist internes Vokabular und sagt nicht, was die Session tut. Besser etwas, das die Rolle nennt (Ausführen + Mergen der verbliebenen Tasks, Phasen 3-5).
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
## Design-Entscheidungen (27.07.-Batch, Sichtprüfung am 2026-08-06 abgeschlossen)
## Sichtprüfung offen (2026-07-27, Batch „Claude do"-Liste)
Alles gebaut + unit-getestet, aber **nicht visuell verifiziert** — Mika prüft:
- **Ctrl+K fokussiert die Suche, `#` ist wieder tippbar** (Binding war `OemQuestion` = `#` auf DE-Layout).
- **Titel-Edit in der Detail-Insel persistiert** (400 ms debounced, wie die Beschreibung); Row-Titel + Merge-Kontext ziehen mit.
- **Spinner beim ConPTY-Start:** Tile erscheint sofort mit „Sitzung wird gestartet…" und deckt Launch-Spec-Roundtrip + Spawn ab. **Nicht** abgedeckt: die Sekunden, die die claude-TUI danach zum ersten Frame braucht (dafür bräuchte es einen Hook auf `TerminalControl.DataReceived`). Bei Launch-Fehler bleibt das Tile jetzt mit Inline-Banner stehen statt zu verschwinden.
- **Spinner beim Refine** ersetzt den Refine-Button in der Row, solange der Run läuft.
- **Interactive-Chip** statt „Parked" auf Tasks mit offener ConPTY-Session; Klick öffnet Mission Control und fokussiert die Pane. Accent-Tint (bewusst dieselbe „live"-Familie wie Running, klar unterschieden vom slate-blauen Parked).
- **Diff-Viewer:** rechter Abstand der `+n n`-Zahlen im File-Tree.
- **Manual-Tasks:** MANUAL-Badge, Kontextmenü-Toggle, Listen-Checkbox „Manuelle Liste"; Queue/Refine/Planning ausgeblendet, ConPTY bleibt.
- **Settings → Allgemein:** Tabelle „Vorgaben pro Modell" (Effort + Max. Durchläufe je haiku/sonnet/opus/fable). Ersetzt das einzelne globale „Max. Durchläufe"-Feld.
Offene Entscheidungen dazu:
Der Sichtprüfungs-Block dieses Batches (Ctrl+K/`#`, Titel-Edit, ConPTY-/Refine-Spinner,
Interactive-Chip, Diff-Viewer-Abstände, Manual-Tasks, Vorgaben-pro-Modell-Tabelle) ist von Mika
verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran hängen:
- Interaktive ConPTY-Sessions bekommen `--effort`, aber **kein** `--model` — die Session läuft weiter unter dem Modell aus Mikas Claude-Config, der Effort kommt aus dem Preset des Modells, das ClaudeDo für die Task auflösen würde. Falls ClaudeDo auch interaktiv das Modell erzwingen soll, ist das ein Folge-Task.
- `AppSettings.DefaultMaxTurns` ist jetzt tatsächlich verdrahtet (`ModelPresets.For(..., global.DefaultMaxTurns)` in `TaskRunner.ResolveConfigAsync`): reiner Fallback für ein Modell, das auch nach `ModelRegistry.TryNormalizeAlias` auf keine Preset-Zeile trifft — vorher war das Feld tot (hartkodierte 30). Hat weiterhin keinen eigenen Editor mehr (nur die Preset-Tabelle pro Modell); Spalte könnte später entfallen, falls das nie zutrifft.
@@ -55,6 +40,7 @@ Offene Entscheidungen dazu:
## Beobachtung (offen — Entscheidung Mika)
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
- ~~**`QueueServiceTests.UsageGate_TransitionLogging_FiresOncePerChange` ist zeitbasiert flaky**~~ — **am 2026-08-06 gefixt**: der feste `Task.Delay(200)` ist durch die schon im selben File vorhandene `AssertStableCountAsync` ersetzt (pollt bis der erste Backstop-Tick geloggt hat, wartet dann eine Karenzzeit und stellt sicher, dass kein weiterer Tick nachlegt). Historischer Befund zur Einordnung: Schlägt reproduzierbar fehl (`Expected 1, Actual 0` Warn-Log-Aufrufe), sowohl solo (`--filter`) als auch im Vollauf, auf einem sauberen `git worktree add` gegen `main` (bdee731) — also **kein** durch diese Abschluss-Session verursachter Regress (die Session hat keine `.cs`-Datei angefasst). Ursache: der Test verlässt sich auf einen festen `Task.Delay(200)`, um mehrere 50-ms-Backstop-Ticks abzuwarten (Kommentar im Test: „Several backstop ticks (50ms interval) all observe the same blocked state"); auf einer stark ausgelasteten Maschine (hier: viele parallele ClaudeDo-Worktrees/Builds) reicht das Fenster nicht immer. Zum Vergleich: derselbe Test lief in einer zweiten, isolierten Verifikation (Scratch-Merge für den Environment-Checks-Task) sauber durch (876/876). Fix wäre ein Poll-basiertes Warten statt fixem Sleep — aber außerhalb des Scopes dieser Doku/Verifikations-Session (keine Code-Änderung angefasst).
---
@@ -62,27 +48,28 @@ Offene Entscheidungen dazu:
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
the context-menu item appears only on lists with a working dir, and the selection dialog has no
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
landing in task descriptions, queued execution, merges).
LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber
gemerged; nur die Sichtprüfung ist noch offen.)
## Offene Verifikation (2026-08-05)
- **List handler owns a task (2026-08-05)** — build + unit tests all green, but **not visually
verified**: start "Let Claude handle it" on a list, confirm exactly one new task appears in that
list (`Idle`, MANUAL badge, title "List handler: <list>"), the Mission Control tile is
task-based (Submit for review button present), Submit for review flips it to
`WaitingForReview`, and the detail pane's diff/merge card shows the full range of everything the
run merged to main (via the new `HandlerBaseCommit`/`HandlerHeadCommit` fallback — no
`WorktreeEntity` is created for this task, so the diff comes from `list.WorkingDir` directly).
- **Roadblock reply field (2026-08-05)** — build + unit tests all green, but **not visually
verified**: on a `Done` (or `WaitingForReview`/`Failed`/`Cancelled`) task with a reported
roadblock, the ROADBLOCK card shows a reply textbox + Send button under the roadblock text.
Check layout/spacing against the AskUser question card it's modeled on, Enter-to-send, the
disabled state + hint text when there's no session ID to resume, and that an override-slot-busy
error shows up in the footer log strip (not a modal) with the typed text still in the field.
Approve should go straight to `Done` with no merge attempt. Design choice: the review range
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
specifically so `list_worktrees`/the Worktrees overview never see it.
- **List handler owns a task (2026-08-05)** — **visuell verifiziert am 2026-08-06** (Lauf über die
Liste `ClaudeDoTests` mit 4 Tasks, davon 2 absichtliche Dubletten): genau ein neuer Task, Mission-
Control-Tile task-basiert mit „Submit for review", Dedupe hat die Dublette erkannt,
Beschreibungen wurden angereichert, Submit → `WaitingForReview`, und die Diff-Karte zeigte alle
drei Dateien des Laufs. Korrektur zur Erwartung oben: der Task-Chip liest sich **„Interactive"**,
nicht `Idle` — das ist korrekt so (`HasInteractiveSession` überschreibt den Status-Chip, solange
die ConPTY-Session läuft). Die drei dabei gefundenen Punkte stehen unter „Bugs" bzw.
„Feature-Wünsche".
- **Roadblock reply field (2026-08-05)** — **Kernfunktion am 2026-08-06 verifiziert**: auf einem
Task mit gemeldetem Roadblock erscheint das Antwortfeld unter dem Roadblock-Text, die Antwort
setzt die Session fort, und der Task landet danach sauber auf `WaitingForReview`. Dabei ist der
Bug oben aufgefallen (Feld deaktiviert, solange der Task seit vor dem Lauf offen ist). Layout der
Roadblock-Karte ist in Ordnung. **Noch offen:** Enter-zum-Senden, und dass ein „Override-Slot
besetzt"-Fehler im Footer-Strip landet (nicht als Modal) mit dem getippten Text weiterhin im
Feld. Design-Entscheidung dazu: der Review-Bereich lebt als zwei nullable Spalten
direkt auf `TaskEntity` (kein Phantom-`WorktreeEntity`), damit `list_worktrees`/die
Worktrees-Übersicht ihn nie sehen.
- **Post-merge verify gate (2026-08-05)** — build + unit tests all green (incl. real-process
`VerifyCommandRunner` exit-code/output/timeout tests and `TaskMergeService` success/failure/
@@ -94,12 +81,60 @@ Offene Entscheidungen dazu:
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
`ping` for timeout) were exercised.
## Offene Verifikation (2026-08-06, Fix-Batch aus der Sichtprüfung)
Fünf Findings der Sichtprüfung sind gefixt, Build + Tests grün, aber **noch nicht in der App
nachgeprüft** (der laufende Build ist älter — erst nach Neuinstallation testbar):
- **NumericUpDown-Leeren wirft nicht mehr:** neuer `KeepLastNumberConverter` bildet beim
ConvertBack `null` auf `BindingOperations.DoNothing` ab, an allen acht nicht-nullbaren
NumericUpDowns im Settings-Modal verdrahtet. Prüfen: Wert löschen und neu tippen — keine
Exception, alter Wert bleibt stehen, bis eine echte Zahl kommt. Damit ist auch der offene Rest
von „MaxTurnsCeiling ändern → Hinweistexte ziehen mit" nachholbar.
- **Verify-Gate greift jetzt auch ohne Worktree:** Approve auf einem List-Handler-Task mit
fehlschlagendem `VerifyCommand` muss `verify_failed` liefern und den Task aus `Done` halten
(vorher ging er kommentarlos auf `Done`).
- **`verify_failed` im Merge-Modal und in der Worktrees-Übersicht:** Modal zeigt statt „Unknown
status" den echten Fehlertext und schließt sich **nicht** automatisch; die Batch-Übersicht
zeigt `VerifyFailed` und markiert die Zeile trotzdem als `Merged` (der Merge ist ja gelandet).
- **Roadblock-Reply/Continue live:** Task offen lassen, während er in den Roadblock läuft — das
Antwortfeld und Continue müssen **ohne** erneutes Öffnen aktiv werden.
- **Cleanup-Log:** ein Worktree-Cleanup über veraltete DB-Zeilen darf keine WARN-Flut mehr im
Footer erzeugen (die „schon erledigt"-Fälle gehen auf Debug).
- **Handoff schließt die alte Kachel:** beim Übergang von Phase 2 auf Phase 3 muss die alte
Merge-Helper-Kachel verschwinden und die neue an ihrer Stelle stehen — keine leere Geister-Pane
mehr. (Entscheidung Mika 2026-08-06: automatisch schließen statt Output erhalten.)
## Offene Verifikation (2026-08-06, Resume planning session)
`planning_session_id` wurde nie befüllt (der Setter hatte keinen Aufrufer), weil die interaktive
Planning-Session ihre claude-Session-Id nie zurückmeldet — Resume konnte deshalb nie
funktionieren. `PlanningSessionManager.ResumeAsync` liest die Id jetzt beim ersten Resume aus dem
Transkript, das Claude Code unter `~/.claude/projects/<encodiertes cwd>/<sessionId>.jsonl` für den
Planning-Worktree ablegt (`PlanningTranscriptLocator`), und persistiert sie. Unit-Tests grün,
**nicht visuell verifiziert**:
- Planning-Session starten, Fenster/Pane schließen, Task erneut öffnen → „Resume" muss die
ConPTY-Pane mit der **fortgesetzten** Unterhaltung öffnen (nicht mit leerem Verlauf).
- Ohne Transkript (z. B. Session nie wirklich gestartet): Fehlermeldung „No Claude session
transcript found…" landet sichtbar im Footer-Error-Strip (`Terminal.StartError`
`ErrorReported`), nicht still.
- **Risiko:** das Verzeichnis-Encoding (`~/.claude/projects/`, jedes Nicht-Alphanumerische wird
`-`) ist undokumentiertes CLI-Verhalten — ändert es sich, findet der Locator nichts und Resume
meldet sauber „cannot resume" (fail-safe, kein falscher Resume).
## Offene Verifikation (2026-08-05, Usage Monitor)
- **Visueller Pass Usage-Pill** (Footer **und** Mission-Control-Header): Text/Tooltip,
Dot-Zustände normal/warn/stale/blocked, Dark/Light.
- **Visueller Pass Usage-Monitor-Modal**: Gauges (dynamisch aus `limits[]`), Modell-/Task-
Tabellen, Info-Bänder (stale/gate-blocked), 7d/30d-Presets + Custom-Range, Dark/Light.
> **Kein Light-Theme.** `App.axaml:6` setzt `RequestedThemeVariant="Dark"` fest, es gibt keinen
> Umschalter und `Tokens.axaml` kennt keine Light-Variante. „Dark/Light"-Checks sind deshalb
> überall aus dieser Datei entfernt — sie waren nie erfüllbar.
- **Visueller Pass Usage-Pill** (Footer **und** Mission-Control-Header) — **am 2026-08-06
verifiziert**: Text/Tooltip lesbar, Dot-Zustände plausibel, Pill in Footer und
Mission-Control-Header identisch.
- **Visueller Pass Usage-Monitor-Modal** — **am 2026-08-06 verifiziert**: Gauges (dynamisch aus
`limits[]`), Info-Bänder (der Throttle-Hinweis „Queue throttled: 1/3 slots (7d)" stand real an),
7d/30d-Presets + Custom-Range. Die Analyse-Tabellen sind ausbaufähig → siehe „UX / Nits".
- **E2E Gate**: das Gate greift real, sobald ein Bucket (`five_hour`/`seven_day`) die
konfigurierte Schwelle reißt — Queue-Nachschub pausiert, laufende Runs/`RunNow`/ConPTY/
Planning/Prime bleiben unberührt — und die Queue nimmt nach dem Reset selbstständig wieder
@@ -108,6 +143,84 @@ Offene Entscheidungen dazu:
undokumentiert und kann sich ändern; bei Ausfall/Formatänderung ist das Gate wirkungslos
(fail-open by design — kein Blocker, aber der Schutz fällt dann aus, ohne dass es auffällt).
### Nachtrag 2026-08-05: 429-Fix (Poll-Kadenz + Refresh-Button)
Der 60s-Poll lief in 429s. Neu: aktivitätsabhängige Kadenz (5 Min. solange ein Task `Running`
ist, sonst 15 Min.), 429-Backoff mit `Retry-After`, und ein „Jetzt aktualisieren"-Button im
Usage-Monitor-Modal (`RefreshUsage``UsageMonitorService.RefreshNowAsync`, 10s-Cooldown).
Unit-Tests grün, **offen**:
- **Visueller Pass Refresh-Button** im Modal — **am 2026-08-06 verifiziert** (Button, Hinweiszeile
„Polled every 5 min while a task runs, otherwise every 15 min.").
- **E2E:** über ≥20 Min. mit und ohne laufenden Task beobachten, dass keine 429s mehr im
Worker-Log auftauchen und die Pill trotzdem aktuell bleibt.
- **Beachten:** die Pill wird jetzt erst nach 3× 15 Min. als `stale` markiert — ein echter
Endpoint-Ausfall fällt vorher nur über `LastError` auf (der `IsStale` sofort setzt).
## Offene Verifikation (2026-08-05, Max-Turns-Ceiling)
Build + unit tests grün (`ResolveMaxTurns`-Klemmung, Repository-Backfill von `model_presets`,
Migration `AddMaxTurnsCeiling` gegen eine Scratch-DB angewendet), aber **nicht visuell
verifiziert**:
- Agent-Settings-Editor (Task **und** Liste): Max-Turns-Feld auf einen Wert über der Ceiling
(Default 80) setzen, Hinweistext unter dem `NumericUpDown` erscheint ("Runs are capped at
{N} turns…").
- Settings → Allgemein → Vorgaben pro Modell: eine Zeile über 80 setzen, derselbe Hinweistext
erscheint unter der Zeile.
- `MaxTurnsCeiling` ist seit `2975f90` selbst editierbar (Settings → Allgemein,
`SettingsModalView.axaml`) — Feld prüfen: Wert ändern, speichern, Hinweistexte oben ziehen mit.
## Offene Verifikation (2026-08-05, Environment Checks / SystemCheckPage)
Checks + SystemCheckPage (`claudedo/06aca9b3…`) und das ExecutableResolver-Wiring im Worker
(`claudedo/40272c0b…`) sind seit 2026-08-06 auf `main` gemerged; die frühere „erst mergen"-
Voraussetzung ist erledigt. Details → `installer-preflight` in `docs/explore-notes/README.md`
und der Abschnitt „Environment Checks" in `src/ClaudeDo.Installer/CLAUDE.md`.
**Update (2026-08-06):** beide Folge-Features sind jetzt implementiert und auf `main`.
- Diagnose-Sektion (Config-Modus/`SettingsWindow`): `Pages/DiagnosePage/` + geteilte
`Checks/CheckListViewModel.cs`/`Checks/CheckListView.xaml` (auch von `SystemCheckPage`
genutzt, keine zweite Implementierung). Unit-getestet
(`tests/ClaudeDo.Installer.Tests/Pages/DiagnosePage/DiagnosePageViewModelTests.cs`).
- „Claude Help Me"-Button: `Core/ClaudeHelpLauncher.cs` + `SystemCheckPageViewModel`/-View,
unit-getestet (`tests/ClaudeDo.Installer.Tests/Core/ClaudeHelpLauncherTests.cs`).
Beides **nicht visuell verifiziert**:
- [x] Help-Me-Button ist deaktiviert, wenn `claude-cli` nicht `Ok` ist oder `claude-auth`
`Failed` ist (bleibt aktiv bei `Unknown`), mit erklärendem Tooltip — unit-getestet.
- [ ] „Claude Help Me" öffnet tatsächlich ein Terminal mit laufender Claude-Session, und die
Session hat den Diagnose-Report gelesen — **nicht verifiziert** (der eigentliche
Terminal-Start/`wt.exe`-Zusammenspiel und die Session-Qualität sind nur über die
injizierte `IProcessLauncher`-Fake getestet, nie mit einem echten Terminal/CLI).
- [ ] Platzierung des Help-Me-Buttons: er sitzt seit dem Merge der Diagnose-Sektion in einer
**eigenen Zeile unter** dem geteilten Check-Listen-Footer (der reservierte Slot *im*
Footer entfiel mit der Extraktion nach `CheckListView.xaml`). Optisch prüfen, ob das
so bleiben soll oder ob der Button in den geteilten Footer gehört.
- [ ] Diagnose-Sektion im Config-Modus: Öffnen von SettingsWindow löst keinen Prüflauf aus, Klick
auf „Erneut prüfen" schon; zeigt die echten installierten Pfade/Ports (nicht die
InstallContext-Defaults), und der laufende Worker auf dem konfigurierten SignalR-Port gilt
nicht als Konflikt — **unit-verifiziert, visueller Durchlauf noch offen.**
Weitere Punkte, gebaut + unit-getestet auf `main`, aber **nicht visuell verifiziert**:
- [ ] SystemCheckPage: Layout, Icon-/Farbwirkung der vier Status (Ok grün / Warnung orange /
Fehler rot / Unbekannt grau — `StatusGreenBrush`/`StatusOrangeBrush`/`StatusRedBrush`/
`StatusGrayBrush`), Lesbarkeit der Hint-Texte, DE und EN.
- [ ] Weiter-Button gesperrt bei einem echten blockierenden Fehler (z. B. `claude` nicht im
PATH → `claude-cli` Error/Failed), und der Grund ist in der Zusammenfassungszeile
sichtbar (nennt den/die blockierenden Check(s) namentlich).
- [ ] „Erneut prüfen" wechselt einen Status live (z. B. git-Identity setzen → Warnung
verschwindet), ohne dass ein zweiter paralleler Lauf startet, wenn währenddessen erneut
geklickt wird.
- [ ] Update-Modus zeigt die SystemCheckPage **nicht** (Wizard bleibt Welcome + Install).
- [ ] Auf einem Rechner mit npm-installiertem `claude.cmd`: `claude-cli`-Check findet es
(Detail-Text „Resolved via a shim…"), und ein Task läuft im Worker durch (bestätigt, dass
`ClaudeProcess`/`ClaudeCliPreflight` den Shim über `cmd.exe /c` tatsächlich startet, nicht
nur, dass der Check ihn findet).
---
## Bewusst verworfen (nicht erneut vorschlagen)
@@ -35,7 +35,7 @@ the engine.
| A1 | Diff viewing | `DiffModalViewModel` (worktree + commit-range), `WorktreeModalViewModel` (file-tree + per-file), `PlanningDiffViewModel` (per-subtask + integration) | `UnifiedDiffParser`, `DiffLinesView` (good) |
| A2 | Agent-config editing | `ListSettingsModalViewModel` (list scope), `AgentSettingsSectionViewModel` (task scope); global lives in `SettingsModalViewModel` | `InheritanceResolver`, `InheritedBadge` (good) |
| A3 | Worktree actions | `WorktreesOverviewModalViewModel` per-row cmds (Merge/Discard/Keep/ForceRemove/ShowDiff/Jump) vs `MergeSectionViewModel` (Merge/OpenDiff) | same `IWorkerClient` calls |
| A4 | Merge display | `AgentStripView` re-displays `MergeSectionViewModel` state | — |
| A4 | Merge display | ~~`AgentStripView` re-displays `MergeSectionViewModel` state~~ — resolved 2026-08-06: `AgentStripView` was dead (never bound after the task-detail redesign); deleted instead of unified | — |
### Bucket B — entry-point sprawl (one backend, many hand-wired doors)
@@ -43,7 +43,7 @@ the engine.
|---|---|---|---|
| B1 | Conflict-resolution seam | 5 copies of `Func<string,string,Task>? RequestConflictResolution` | `WorktreesOverviewModalViewModel.cs:83`, `DiffModalViewModel.cs:75`, `MergeModalViewModel.cs:33`, `MergeSectionViewModel.cs:51`, `DetailsIslandViewModel.cs:347` (delegates). Threaded through `MainWindow.axaml.cs:81`, `IslandsShellViewModel.cs:49/202`, `DiffModalViewModel.cs:103`, `MergeSectionViewModel.cs:159` |
| B2 | Diff (open) | 34 | MergeSection "Open Diff", TaskHeaderBar "Review Merged Diff", WorktreesOverview "Show Diff", Planning "Review Combined" |
| B3 | List Settings dialog | 3 | Lists context menu, Tasks header button, shell bridge `IslandsShellViewModel.cs:190-194` |
| B3 | List Settings dialog | 2 (was 3 Lists context menu entry removed 2026-08-06) | Tasks header button, double-click on a list row, shell bridge `IslandsShellViewModel.cs:190-194` |
| B4 | Worktrees Overview | 23 | Repos menu (global), Lists context menu (per-list) |
| B5 | Repo Import | 2 | Repos menu, Lists footer button |
@@ -0,0 +1,235 @@
# Planning-Chain Children: Fork Base Commit (Design Options, No Implementation)
**Date:** 2026-08-06
**Status:** Options evaluated, recommendation given — decision pending (Mika)
**Scope:** Analysis only. No production code changed by this document.
## Problem
The planning chain gives **ordering**, not **code inheritance**. `PlanningChainCoordinator.SetupChainAsync`
(`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs:36-72`) links children via
`BlockedByTaskId` (child[i] blocks on child[i-1]) so they run one at a time. But each child's
worktree is still created from **main's current HEAD**, not from the predecessor's branch, so
child N+1 never sees child N's (unmerged) work.
**Observed failure (unit `44241dcb`, "Installer: Environment-Checks", 2026-08-05):** 7 of 9
children succeeded; the two that depended on a sibling's output could not:
- `4e196058` ("Claude Help Me" button) — reported: *"the task spec assumes an existing
SystemCheckPage/EnvironmentCheckReport/ExecutableResolver, but that code only exists on an
unmerged sibling branch (claudedo/06aca9b3...). A fast-forward merge of that branch was
attempted ... but was denied twice by the auto-mode permission classifier. No implementation
work was done."*
- `c22cdd06` (Diagnose section) — *"Blocked before implementation could start."*
Both correctly reported `CLAUDEDO_BLOCKED` and committed nothing (`ahead: 0`). A second-order
symptom of the same root cause: `ExecutableResolver.cs` was independently created **twice**
(`40272c0b`, `06aca9b3`, near-identical) and collided as an add/add conflict at unit-merge time.
## Ist-Zustand (verified against commit `816f247`, 2026-08-06)
### Where the base commit is chosen
`WorktreeManager.ResolveBaseCommitAsync``src/ClaudeDo.Worker/Runner/WorktreeManager.cs:191-207`:
```csharp
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
{
if (task.ParentTaskId is not null)
{
var parent = ...;
if (parent is not null && parent.PlanningPhase == PlanningPhase.None)
{
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit;
if (parentHead is not null)
return parentHead;
}
}
return await _git.RevParseHeadAsync(workingDir, ct); // planning children land here
}
```
**A "don't fork from main" mechanism already exists** — but only for *improvement* children
(a non-planning parent's own follow-up subtasks), which base off `task.ParentTaskId`'s worktree
HEAD. The guard `parent.PlanningPhase == PlanningPhase.None` explicitly **excludes** planning
children: their `ParentTaskId` points at the planning parent (which has no worktree of its own),
not at a sibling, so this branch never fires for them and they fall through to `RevParseHeadAsync`
= main HEAD. This is a deliberate exclusion in the existing code, not an oversight — it simply
never anticipated that a planning child's *predecessor in the chain* (not its parent) might be
the thing to inherit from.
Called from `WorktreeManager.CreateAsync` (`:35`), invoked by `TaskRunner` (`Runner/TaskRunner.cs:309`)
at the moment a task transitions to `Running` — i.e. worktree creation happens per-run, not once
at plan-finalize time.
### Children already run strictly sequentially — this is not a new constraint
`SetupChainAsync` sets `BlockedByTaskId` on every child but the first
(`Planning/PlanningChainCoordinator.cs:61-69`); the queue picker only claims rows with
`BlockedByTaskId IS NULL`. `OnChildFinishedAsync` (`:92-120`) unblocks the successor **only**
after the predecessor reaches `Done` (and cascades cancellation down the chain on
Failed/Cancelled). `OverrideSlotService.RunNow` (`Queue/OverrideSlotService.cs:32-42`) is the one
path that bypasses this — it calls `StartRunningAsync` directly with no `BlockedByTaskId` check,
so a user can manually force a later child to run out of turn.
Net: under the normal (queue-driven) path, by the time child N+1's worktree is created, child N
is already terminal. Forking N+1 from N's branch tip instead of main's HEAD does **not** introduce
any new concurrency constraint on the normal path — the sequencing already exists. It only matters
for the `RunNow` bypass edge case (see Option 1 below).
### The merge side already treats the unit as a sequential chain, twice over
`PlanningMergeOrchestrator.DrainAsync` (`Planning/PlanningMergeOrchestrator.cs:183-229`) merges
`Done` children into `targetBranch` **one at a time, in `SortOrder`**, via
`TaskMergeService.MergeAsync`; the first conflict pauses the whole drain
(`PlanningMergeConflict`, state kept for `ContinueAsync`/`AbortAsync`), and the first
non-conflict failure **aborts the drain outright** — remaining children are never merged and the
parent never reaches `Done` (`:208-215`).
`PlanningAggregator.BuildIntegrationBranchAsync` (`Planning/PlanningAggregator.cs:81-126`) — used
for the pre-approve combined-diff preview — does the same thing again: builds a scratch
integration branch off `targetBranch` and `MergeNoFfAsync`s each child's branch in, in
`SortOrder`, stopping at the first conflict.
So **both** the actual unit-merge and its preview already model the child set as an ordered
sequence where one bad link can stall everything downstream. The only place in the whole pipeline
that still treats planning children as N independent forks of main is worktree creation.
## Options evaluated
### Option 1 — child forks from the predecessor's branch
Extend `ResolveBaseCommitAsync` (or a planning-specific sibling of it) to also handle planning
children: look up the chain predecessor via `BlockedByTaskId` (not `ParentTaskId` — that points at
the planning parent, which has no worktree) and use its `WorktreeEntity.HeadCommit ?? BaseCommit`
the same way the improvement-child path already does.
**What actually breaks, given the Ist-Zustand above:**
- *Not* a new concurrency constraint (see above) — the chain already serializes execution.
- *Not* a new "one failure blocks everyone" behavior at merge time — `DrainAsync` already aborts
the whole drain on the first non-conflict failure, and the integration-branch preview already
stops at the first conflict. Option 1 brings execution-time behavior in line with what merge-time
behavior already is, rather than introducing a new failure mode.
- **Genuinely new:** child N+1's branch now contains N's commits as ancestors. When N is later
merged into `targetBranch` with `--no-ff` and N+1 is merged afterward, N+1's diff against N's
content is empty (identical trees) — merges cleanly as a no-op for that slice, verified by the
same mechanism `DrainAsync` already uses. No new conflict class is introduced; if anything this
*removes* one: the `ExecutableResolver.cs` add/add collision would not have occurred, since N+1
would start from a tree that already has the file.
- **Genuinely new edge case:** `OverrideSlotService.RunNow` on a chain member whose predecessor
hasn't produced a `WorktreeEntity`/`HeadCommit` yet. Needs an explicit fallback to main HEAD —
the same `?? ` pattern the improvement-child branch already uses for a predecessor with no
`HeadCommit` (never committed) covers most of this; a predecessor that was never even *run* needs
the same fallback-to-`RevParseHeadAsync` the code already falls through to today.
- **Genuinely new edge case:** a discarded/failed predecessor's worktree row still carries its last
`HeadCommit`/`BaseCommit` (the row isn't deleted on discard, only `State` flips) — same as the
improvement-child path already tolerates, so no new handling needed there.
- Children still go straight to `Done` with no individual review (Unified Parent Model), so there
is no "reject and rewrite a middle child after a successor already forked from it" scenario to
worry about — that action doesn't exist in the current state machine.
**Price:** deeper branch chains (cosmetic — they're squashed away by the sequential merge/prune
regardless); one new fallback path for the `RunNow` bypass. Meaningfully smaller than it first
looks, because it extends an existing pattern (improvement children) into a lane whose execution
and merge sides are *already* sequential — it only fixes the one place that wasn't.
### Option 2 — merge predecessor to main immediately on success, before the successor starts
Keeps children independent worktrees (forked from main-as-it-is-now, updated after each merge),
but requires a real merge to `main` per child, outside of Approve.
**Breaks:**
- Directly contradicts the documented invariant "**Approve is the single review+merge action**"
(`src/ClaudeDo.Worker/CLAUDE.md` → Status Model; `docs/explore-notes/review-merge.md` → "Approve
= merge the whole unit"). Unreviewed work would land on `main` automatically, mid-chain, before
the parent — or the user — ever sees it.
- Requires calling `TaskMergeService.MergeAsync` from `OnChildFinishedAsync` (state-transition
code), duplicating what `PlanningMergeOrchestrator` already owns, and doing it against a
`VerifyCommand`-gated repo N times instead of once — if the gate is configured, a mid-chain
verify failure now has to be handled somewhere there's currently no error path for it (parent is
still `WaitingForChildren`, not under merge orchestration yet).
- Unavoidably touches `TaskStateService`/status-transition semantics, which this task's scope
explicitly excludes ("Die Status-Logik oder `TaskStateService` anfassen" is out of scope) — this
option cannot be implemented without doing exactly that.
**Verdict:** rejected outright — it isn't just costly, it conflicts with a stated architectural
invariant and this task's own non-goals.
### Option 3 — child may merge the predecessor's branch into its own worktree if it needs to
Least invasive to the base-commit mechanism; delegates the decision to the agent at runtime.
**Breaks:**
- This is *exactly* what the blocked child in the observed incident already tried, and it was
denied twice by the auto-mode permission classifier. The real blocker isn't a missing
capability, it's that `git merge` trips the classifier's "risky/hard-to-reverse" heuristic
regardless of target — even though a merge confined to the task's own isolated worktree (never
touching the shared `workingDir`) is materially lower-risk than a merge on a shared checkout.
Fixing this option means carving a narrower permission rule (allow `git merge` only inside a
path under the task's own worktree root), which is a security-policy change, not a merge-model
change.
- Even with permission granted, it still relies on the agent (a) noticing it's missing a
prerequisite, (b) correctly identifying which sibling branch has it, and (c) successfully
resolving any conflict that surfaces — three separate failure points per occurrence, decided
fresh by an LLM each time rather than encoded once in the pipeline.
- Doesn't help children that fail *before* they'd even think to look (e.g., a child whose first
action is reading a file that doesn't exist yet has no signal to act on).
**Verdict:** possible as a narrow permission-scope fix layered *on top of* Option 1 (so an agent
that still needs something from further back than its immediate predecessor isn't stuck), but not
a substitute for it — on its own it reproduces the exact failure mode from the incident, just with
one fewer denial.
### Option 4 — change nothing; require independent children at planning time
Zero code changes; pushes the constraint into plan quality.
**Breaks:**
- No enforcement exists today (`PlanningSessionManager` doesn't validate structural independence
between proposed subtasks), and reliably detecting "child B depends on code child A will write"
from a plan draft is itself an unsolved code-review problem — not something a prompt tweak
guarantees.
- A shared-foundation-plus-N-consumers decomposition (exactly the `44241dcb` shape: environment
checks feeding two UI consumers) is often the *correct* decomposition, not a planning mistake.
Banning it either forces over-merging into one giant child (defeating the purpose of splitting)
or requires the planner to reject a structurally sound plan.
- Reproduces the observed failure verbatim under the same conditions: nothing about this option
would have caught `44241dcb` before it shipped two dead children.
**Verdict:** cheapest to write down, but doesn't fix the problem — it relocates it to "the plan
looked independent but wasn't," which is what already happened.
## Recommendation
**Option 1**, with the `RunNow`-bypass fallback described above, and with Option 3's narrower
permission-scope fix as an optional follow-up (not a prerequisite) for cases where a child needs
something from further back in the chain than its immediate predecessor.
**Why:** Option 1 is not a new mechanism — it's closing the one gap in a pattern that already
exists twice in this codebase: `WorktreeManager.ResolveBaseCommitAsync` already forks improvement
children from their parent's HEAD instead of main, and both `PlanningMergeOrchestrator.DrainAsync`
and `PlanningAggregator.BuildIntegrationBranchAsync` already treat the child set as an ordered
merge sequence where the first failure stalls everything after it. Planning-child worktree
creation is the outlier, not the rule. Extending the existing predecessor-lookup pattern (keyed
off `BlockedByTaskId` instead of `ParentTaskId` for this lane) makes the "sees predecessor's work"
guarantee hold everywhere the chain already implies it, without touching `TaskStateService`, the
review model, or introducing a merge-time failure mode that doesn't already exist. Options 2 and 4
either conflict with a stated invariant/this task's own non-goals, or fail to address the incident
at all; Option 3 alone reproduces the incident's exact failure.
**Price to pay knowingly:** a chain member that never got a chance to run (no `WorktreeEntity` yet)
needs an explicit main-HEAD fallback when its successor is forced via `RunNow` — a few lines,
mirroring the null-coalescing fallback the improvement-child path already has. No other new failure
surface was found.
## Explicitly out of scope (per task)
- Implementing Option 1 or any other option — Mika decides first.
- Any change to `TaskStateService` or status-transition logic.
- Changing blocked-child visibility/behavior — that's task `001ee94a`, running in parallel; it only
touches MCP visibility, no production code overlap with this document.
+1
View File
@@ -22,6 +22,7 @@
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
<converters:KeepLastNumberConverter x:Key="KeepLastNumber"/>
</ResourceDictionary>
</Application.Resources>
+4 -10
View File
@@ -8,19 +8,13 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
- `App.axaml` / `App.axaml.cs` — Avalonia application lifecycle, main window creation, static `ServiceProvider` accessor
- `ViewLocator.cs` — reflection-based IDataTemplate that maps ViewModels to Views by naming convention
## Dependencies
- Avalonia 12.0.0 (Desktop, Fluent theme, Inter fonts)
- CommunityToolkit.Mvvm 8.4.1
- Microsoft.Extensions.DependencyInjection 8.0.1
- Microsoft.AspNetCore.SignalR.Client 8.0.11
- Microsoft.Data.Sqlite 8.0.11
- Project references: ClaudeDo.Data, ClaudeDo.Ui
Project references: `ClaudeDo.Data`, `ClaudeDo.Ui`. Package versions are in the `.csproj` — see
the root CLAUDE.md for the tech stack.
## DI Registration Pattern
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `DiffViewerViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation (`Func<DiffViewerViewModel>` for the diff viewer); `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
- **Singletons** `IDbContextFactory`, all repositories, `GitService`, `WorkerClient`, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`, `INotesApi`, `InstallerLocator`/`WorkerLocator`, the three island VMs, and `IslandsShellViewModel` (the window's DataContext)
- **Transients** — modal VMs, several exposed as `Func<T>` factories for on-demand dialog creation (e.g. `Func<DiffViewerViewModel>`). `ConflictResolverViewModel` uses a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`).
## Notes
+80 -28
View File
@@ -4,52 +4,104 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit (nullable; review range for a worktree-less "list handler" host task — Mission Control's "Let Claude handle it" — which commits straight into the list's working dir instead of a per-task worktree: `HandlerBaseCommit` is stamped to the list repo's HEAD when the host task is created, `HandlerHeadCommit` when it's submitted for review; the Worker's `SubmitTaskForReview`/`GetTaskDiff` and the Ui's `DetailsIslandViewModel`/`MergeSectionViewModel` fall back to this pair whenever `Worktree` is null). Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId.
- Status / PlanningPhase / BlockedByTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
- `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate (see `ClaudeDo.Worker/CLAUDE.md` → TaskMergeService): null/blank = today's behavior, no gate.
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable; SHA of the merge commit this worktree's branch produced on the target branch, stamped by `TaskMergeService` the moment a merge/continue-merge succeeds — the only thing that makes `revert_merge` possible without heuristically searching `git log`; null for any worktree merged before this field existed), State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`)
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) , `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`), and `UsageGateFiveHourPct` / `UsageGateSevenDayPct` (int, defaults 80/90, columns `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` — percentage of the 5h/7d Claude usage window at which the autonomous queue pauses; `0` = gate off; `AppSettingsRepository.UpdateAsync` clamps both to 0..100)
- **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1200) and never throw — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns = 30)` always returns a usable row: `model` is resolved through `ModelRegistry.TryNormalizeAlias` first, so a full CLI model id (e.g. `claude-sonnet-4-6`) still hits its alias's preset row instead of missing every lookup and falling through; only a model that normalizes to nothing recognized falls back to a synthesized row (`EffortRegistry.DefaultLevel` + `fallbackMaxTurns` — callers pass `AppSettings.DefaultMaxTurns` here so that setting has a real effect instead of a hardcoded number). Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.
- **ModelRegistry.TryNormalizeAlias** — non-throwing counterpart to `NormalizeAlias` for the run path: exact alias match, then substring match against a full model id, else `null`. Never throws, unlike `NormalizeAlias` (which stays the strict, throwing validator for `add_task`/planning model input).
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag)
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md).
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
- **TaskRunEntity** — per-run record: session_id, turns, result, structured output, exit code, log path, nullable `Model` (what the run actually executed with), and `TokensIn`/`TokensOut`/`CacheReadTokens`/`CacheWriteTokens`. ⚠️ Token fields come from the **session transcript**, not the stream-json event, as a per-run delta → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, column `days_of_week`), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on selected weekdays; no date range.
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → `daily_notes`
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments`
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
### AppSettingsEntity
Beyond the basics it carries:
| Property | Column | Default | Note |
|---|---|---|---|
| `DefaultMaxTurns` | `default_max_turns` | **40** | Lowered from 100; `AddMaxTurnsCeiling` backfilled the seeded row. |
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. |
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
### Model / effort registries
- **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
- **ModelRegistry** — `NormalizeAlias` is the strict, **throwing** validator for `add_task`/planning model input. `TryNormalizeAlias` is the non-throwing counterpart for the run path (exact alias match, then substring match against a full model id, else `null`). `ByCostAscending` = the cost order the prompts use.
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag).
## Repositories
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued Running` claim lives in the
Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged and stamps MergeCommit in one update — the only writer of MergeCommit)
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
- **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
- **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync`
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`.
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
- **DailyNoteRepository**, **WeekReportRepository**, **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
`TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment
dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
## Infrastructure
- **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
- **ClaudeDoDbContext** — EF Core DbContext; WAL mode + foreign keys via `UseSqlite` options
- **IDbContextFactory\<ClaudeDoDbContext\>** — registered in DI; used by singleton consumers (e.g. the Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root `~/.todo-app`
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
- **AttachmentStore** — dependency-free file store; default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and path-traversal/containment guard. Also exposes `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, and `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live outside git worktrees intentionally.
- **AttachmentStore** — dependency-free file store, default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and a path-traversal/containment guard. Also `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live **outside** git worktrees intentionally.
## Git
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), revert (`RevertMergeCommitAsync``git revert --no-edit -m 1 <sha>`, reverts a merge commit against its first parent; `RevertAbortAsync`; `IsMidRevertAsync``REVERT_HEAD` presence, mirrors `IsMidMergeAsync`'s `MERGE_HEAD`), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo. Revert never resets/rewrites — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions.
**GitService** — async wrapper around the git CLI (`ProcessStartInfo`, no shell):
- Worktrees: add (**serialized** to avoid a commondir race), remove, prune, list paths for branch
- Branches: current, list local, checkout, delete
- Staging/commit: status porcelain, add-all, add-path, commit via stdin
- Diffs: working tree, branch vs base, commit range `base..head` (shows a merged task's diff after the worktree is gone), per-file, diff-stat, committed files, has-changes
- Merge: ff-only, no-ff, abort, mid-merge detection (`MERGE_HEAD`), conflicted files
- Revert: `RevertMergeCommitAsync` (`git revert --no-edit -m 1 <sha>`), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`)
- `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working
directory it operates on is shared with other concurrent sessions.
## Schema
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
Tables (one per line so parallel migrations don't collide on the same line):
- `lists`
- `tasks`
- `worktrees`
- `list_config`
- `task_runs`
- `subtasks`
- `app_settings`
- `prime_schedules`
- `daily_notes`
- `week_reports`
- `task_attachments`
Managed by EF Core migrations in `Migrations/`**`ls Migrations/` is the authoritative history**;
don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`), and
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
## Conventions
- Enum <-> string mapping via EF Core `ValueConverter` (configured in `IEntityTypeConfiguration<T>`)
- Entity configurations live in the `Configuration/` folder
- Enum string mapping via EF Core `ValueConverter`, configured in `IEntityTypeConfiguration<T>`
- Entity configurations live in `Configuration/`
- Primary keys are `init`-only strings (GUIDs assigned at creation)
- All methods are async with CancellationToken where applicable
+1
View File
@@ -17,6 +17,7 @@
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Worker" />
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
<InternalsVisibleTo Include="ClaudeDo.Data.Tests" />
</ItemGroup>
</Project>
@@ -18,10 +18,13 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.DefaultModel)
.HasColumnName("default_model").IsRequired().HasDefaultValue("sonnet");
builder.Property(s => s.DefaultMaxTurns)
.HasColumnName("default_max_turns").IsRequired().HasDefaultValue(30);
.HasColumnName("default_max_turns").IsRequired().HasDefaultValue(40);
builder.Property(s => s.DefaultPermissionMode)
.HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions");
builder.Property(s => s.MaxTurnsCeiling)
.HasColumnName("max_turns_ceiling").IsRequired().HasDefaultValue(80);
builder.Property(s => s.MaxParallelExecutions)
.HasColumnName("max_parallel_executions").IsRequired().HasDefaultValue(1);
@@ -52,6 +55,11 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.UsageGateSevenDayPct)
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
builder.Property(s => s.UsageThrottleSoftPct)
.HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleHardPct)
.HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
}
}
@@ -95,6 +95,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
builder.Property(t => t.InteractiveSessionId).HasColumnName("interactive_session_id");
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
@@ -24,6 +24,8 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity
builder.Property(r => r.TurnCount).HasColumnName("turn_count");
builder.Property(r => r.TokensIn).HasColumnName("tokens_in");
builder.Property(r => r.TokensOut).HasColumnName("tokens_out");
builder.Property(r => r.CacheReadTokens).HasColumnName("cache_read_tokens");
builder.Property(r => r.CacheWriteTokens).HasColumnName("cache_write_tokens");
builder.Property(r => r.LogPath).HasColumnName("log_path");
builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
@@ -0,0 +1,116 @@
using SysEnvironment = System.Environment;
namespace ClaudeDo.Data.Environment;
public sealed record ResolvedExecutable(string Path, bool IsShim);
public sealed record ShimStartInfo(string FileName, string Arguments);
/// <summary>
/// Resolves a command the way Windows' CreateProcess/PATH search does, but also finds
/// non-.exe shims (.cmd/.bat/.ps1) that UseShellExecute=false alone would miss.
/// </summary>
public static class ExecutableResolver
{
private const string DefaultPathExt = ".COM;.EXE;.BAT;.CMD";
// Known npm/claude install locations to try when PATH search comes up empty.
private static readonly string[] FallbackDirectoryTemplates =
{
"%APPDATA%\\npm",
"%LOCALAPPDATA%\\Programs\\claude",
"%USERPROFILE%\\.local\\bin",
};
public static ResolvedExecutable? Resolve(string command, string? pathOverride = null, string? pathExtOverride = null)
{
var pathExts = ParsePathExt(pathExtOverride);
if (LooksLikePath(command))
{
return ResolveAsPath(command, pathExts);
}
var directories = ParsePath(pathOverride);
foreach (var dir in directories)
{
var resolved = ResolveInDirectory(dir, command, pathExts);
if (resolved is not null) return resolved;
}
foreach (var template in FallbackDirectoryTemplates)
{
var dir = SysEnvironment.ExpandEnvironmentVariables(template);
var resolved = ResolveInDirectory(dir, command, pathExts);
if (resolved is not null) return resolved;
}
return null;
}
/// <summary>Expanded fallback directories tried when PATH search comes up empty (for diagnostics).</summary>
public static IReadOnlyList<string> FallbackDirectories() =>
FallbackDirectoryTemplates.Select(SysEnvironment.ExpandEnvironmentVariables).ToList();
public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList<string> arguments)
{
var parts = new List<string> { "/c", Quote(shimPath) };
parts.AddRange(arguments.Select(Quote));
return new ShimStartInfo("cmd.exe", string.Join(' ', parts));
}
private static bool LooksLikePath(string command) =>
command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar);
private static ResolvedExecutable? ResolveAsPath(string command, IReadOnlyList<string> pathExts)
{
if (File.Exists(command)) return new ResolvedExecutable(command, IsShimExtension(Path.GetExtension(command)));
if (Path.HasExtension(command)) return null;
foreach (var ext in pathExts)
{
var candidate = command + ext;
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
}
return null;
}
private static ResolvedExecutable? ResolveInDirectory(string directory, string command, IReadOnlyList<string> pathExts)
{
if (!Directory.Exists(directory)) return null;
if (Path.HasExtension(command))
{
var candidate = Path.Combine(directory, command);
return File.Exists(candidate) ? new ResolvedExecutable(candidate, IsShimExtension(Path.GetExtension(candidate))) : null;
}
foreach (var ext in pathExts)
{
var candidate = Path.Combine(directory, command + ext);
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
}
return null;
}
private static bool IsShimExtension(string extension) =>
!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)
&& !extension.Equals(".com", StringComparison.OrdinalIgnoreCase);
private static IReadOnlyList<string> ParsePathExt(string? pathExtOverride)
{
var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt;
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static IReadOnlyList<string> ParsePath(string? pathOverride)
{
var raw = pathOverride ?? SysEnvironment.GetEnvironmentVariable("PATH") ?? "";
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
}
+16
View File
@@ -26,6 +26,22 @@ public sealed class GitService
return stdout.Trim();
}
/// <summary>
/// True if <paramref name="ancestorSha"/> is an ancestor of (or equal to) <paramref name="descendantSha"/>,
/// via `git merge-base --is-ancestor`. Null means the answer can't be determined (e.g. the commit is
/// unknown in this repo) — callers must treat that as "unknown", never as "not an ancestor".
/// </summary>
public async Task<bool?> IsAncestorAsync(string repoDir, string ancestorSha, string descendantSha, CancellationToken ct = default)
{
var (exitCode, _, _) = await RunGitAsync(repoDir, ["merge-base", "--is-ancestor", ancestorSha, descendantSha], ct);
return exitCode switch
{
0 => true,
1 => false,
_ => null,
};
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
@@ -0,0 +1,843 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805132052_AddMaxTurnsCeiling")]
partial class AddMaxTurnsCeiling
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddMaxTurnsCeiling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "default_max_turns",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 40,
oldClrType: typeof(int),
oldType: "INTEGER",
oldDefaultValue: 30);
migrationBuilder.AddColumn<int>(
name: "max_turns_ceiling",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 80);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "default_max_turns", "max_turns_ceiling" },
values: new object[] { 40, 80 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "max_turns_ceiling",
table: "app_settings");
migrationBuilder.AlterColumn<int>(
name: "default_max_turns",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 30,
oldClrType: typeof(int),
oldType: "INTEGER",
oldDefaultValue: 40);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "default_max_turns",
value: 100);
}
}
}
@@ -0,0 +1,844 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805132801_AddRunCacheTokens")]
partial class AddRunCacheTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddRunCacheTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "cache_read_tokens",
table: "task_runs",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "cache_write_tokens",
table: "task_runs",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "cache_read_tokens",
table: "task_runs");
migrationBuilder.DropColumn(
name: "cache_write_tokens",
table: "task_runs");
}
}
}
@@ -0,0 +1,850 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805133429_AddUsageThrottleThresholds")]
partial class AddUsageThrottleThresholds
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct");
b.Property<int>("UsageThrottleSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleHardPct = 65,
UsageThrottleSoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddUsageThrottleThresholds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "usage_throttle_hard_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 65);
migrationBuilder.AddColumn<int>(
name: "usage_throttle_soft_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 50);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "usage_throttle_hard_pct", "usage_throttle_soft_pct" },
values: new object[] { 65, 50 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "usage_throttle_hard_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "usage_throttle_soft_pct",
table: "app_settings");
}
}
}
@@ -0,0 +1,869 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260806111454_AddInteractiveSessionId")]
partial class AddInteractiveSessionId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct");
b.Property<int>("UsageThrottleSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleHardPct = 65,
UsageThrottleSoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddInteractiveSessionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "interactive_session_id",
table: "tasks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "interactive_session_id",
table: "tasks");
}
}
}
@@ -43,7 +43,7 @@ namespace ClaudeDo.Data.Migrations
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
@@ -66,6 +66,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
@@ -100,6 +106,18 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct");
b.Property<int>("UsageThrottleSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -129,13 +147,16 @@ namespace ClaudeDo.Data.Migrations
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleHardPct = 65,
UsageThrottleSoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
@@ -442,6 +463,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -576,6 +601,14 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
+10 -1
View File
@@ -8,9 +8,13 @@ public sealed class AppSettingsEntity
public string DefaultClaudeInstructions { get; set; } = string.Empty;
public string DefaultModel { get; set; } = "sonnet";
public int DefaultMaxTurns { get; set; } = 100;
public int DefaultMaxTurns { get; set; } = 40;
public string DefaultPermissionMode { get; set; } = "auto";
// Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run
// starts. Guards against runaway sessions regardless of what a task/list override requests.
public int MaxTurnsCeiling { get; set; } = 80;
public int MaxParallelExecutions { get; set; } = 1;
public string WorktreeStrategy { get; set; } = "sibling";
@@ -38,4 +42,9 @@ public sealed class AppSettingsEntity
// Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off.
public int UsageGateFiveHourPct { get; set; } = 80;
public int UsageGateSevenDayPct { get; set; } = 90;
// Percentage of the 5h/7d Claude usage window at which the queue starts throttling
// parallelism ahead of the hard gate above. 0 = that stage off.
public int UsageThrottleSoftPct { get; set; } = 50;
public int UsageThrottleHardPct { get; set; } = 65;
}
+6
View File
@@ -60,6 +60,12 @@ public sealed class TaskEntity
public string? HandlerBaseCommit { get; set; }
public string? HandlerHeadCommit { get; set; }
// The claude session id an embedded ConPTY interactive task session is (or was last)
// running under -- generated up front and persisted before launch so a closed/aborted
// session can be resumed even if the process never got past startup. Cleared implicitly
// whenever the task's worktree is recreated (a fresh worktree has nothing to resume into).
public string? InteractiveSessionId { get; set; }
public string? ParentTaskId { get; set; }
public string? PlanningSessionId { get; set; }
public string? PlanningSessionToken { get; set; }
@@ -15,6 +15,8 @@ public sealed class TaskRunEntity
public int? TurnCount { get; set; }
public int? TokensIn { get; set; }
public int? TokensOut { get; set; }
public int? CacheReadTokens { get; set; }
public int? CacheWriteTokens { get; set; }
public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
+3 -3
View File
@@ -11,16 +11,16 @@ public static class Paths
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path must not be empty.", nameof(path));
var expanded = Environment.ExpandEnvironmentVariables(path);
var expanded = System.Environment.ExpandEnvironmentVariables(path);
if (expanded.StartsWith("~", StringComparison.Ordinal))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
expanded = home + expanded[1..];
}
if (!Path.IsPathRooted(expanded))
expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory);
expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory);
return Path.GetFullPath(expanded);
}
+208 -20
View File
@@ -1,39 +1,185 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial, MergeHelperHandoff }
/// <summary>
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
/// </summary>
public enum PromptFileState
{
/// <summary>No override file — the bundled default is in effect.</summary>
Missing,
/// <summary>File exists and is byte-for-byte (normalized) the current default.</summary>
MatchesCurrentDefault,
/// <summary>File exists, differs from today's default, but was recorded as an unedited copy of a past default — safe to reconcile away.</summary>
MatchesKnownPastDefault,
/// <summary>File exists and diverges from the default with no matching recorded hash — a real user customization.</summary>
Edited
}
public static class PromptFiles
{
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
public static string PathFor(PromptKind kind) => kind switch
public static string PathFor(PromptKind kind, string? root = null) =>
Path.Combine(root ?? Root, FileNameFor(kind));
private static string FileNameFor(PromptKind kind) => kind switch
{
PromptKind.System => Path.Combine(Root, "system.md"),
PromptKind.Planning => Path.Combine(Root, "planning-system.md"),
PromptKind.PlanningInitial => Path.Combine(Root, "planning-initial.md"),
PromptKind.Retry => Path.Combine(Root, "retry.md"),
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
PromptKind.Refine => Path.Combine(Root, "refine.md"),
PromptKind.MergeHelper => Path.Combine(Root, "merge-helper-system.md"),
PromptKind.MergeHelperInitial => Path.Combine(Root, "merge-helper-initial.md"),
PromptKind.System => "system.md",
PromptKind.Planning => "planning-system.md",
PromptKind.PlanningInitial => "planning-initial.md",
PromptKind.Retry => "retry.md",
PromptKind.DailyPrep => "daily-prep.md",
PromptKind.WeeklyReport => "weekly-report.md",
PromptKind.ImprovementChild => "improvement-child.md",
PromptKind.Refine => "refine.md",
PromptKind.MergeHelper => "merge-helper-system.md",
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
public static void EnsureExists(PromptKind kind)
/// <summary>Classify an override file against the bundled default and the recorded default-hash log.</summary>
public static PromptFileState Classify(PromptKind kind, string? root = null)
{
Directory.CreateDirectory(Root);
var path = PathFor(kind);
if (File.Exists(path)) return;
File.WriteAllText(path, DefaultFor(kind));
var effectiveRoot = root ?? Root;
var path = PathFor(kind, effectiveRoot);
if (!File.Exists(path)) return PromptFileState.Missing;
var normalized = Normalize(File.ReadAllText(path));
if (normalized == Normalize(DefaultFor(kind))) return PromptFileState.MatchesCurrentDefault;
var hashes = LoadDefaultHashes(effectiveRoot);
if (hashes.TryGetValue(kind.ToString(), out var recorded) && recorded == HashOf(normalized))
return PromptFileState.MatchesKnownPastDefault;
return PromptFileState.Edited;
}
public static string? ReadOrNull(PromptKind kind)
/// <summary>Write an explicit override. If the content matches today's default, its hash is recorded so a
/// later default change can reconcile it away automatically instead of freezing it forever.</summary>
public static void Save(PromptKind kind, string content, string? root = null)
{
var path = PathFor(kind);
var effectiveRoot = root ?? Root;
Directory.CreateDirectory(effectiveRoot);
File.WriteAllText(PathFor(kind, effectiveRoot), content);
var normalized = Normalize(content);
var hashes = LoadDefaultHashes(effectiveRoot);
if (normalized == Normalize(DefaultFor(kind)))
hashes[kind.ToString()] = HashOf(normalized);
else
hashes.Remove(kind.ToString());
SaveDefaultHashes(effectiveRoot, hashes);
}
/// <summary>Delete the override file (if any) so the bundled default takes effect again.</summary>
public static void ResetToDefault(PromptKind kind, string? root = null)
{
var effectiveRoot = root ?? Root;
var path = PathFor(kind, effectiveRoot);
if (File.Exists(path)) File.Delete(path);
var hashes = LoadDefaultHashes(effectiveRoot);
if (hashes.Remove(kind.ToString())) SaveDefaultHashes(effectiveRoot, hashes);
}
/// <summary>Startup sweep: any override file that only ever matched a past (now superseded) default,
/// and was never actually edited, is dropped so the current default reaches real runs again.</summary>
public static void ReconcileStaleDefaults(string? root = null)
{
var effectiveRoot = root ?? Root;
foreach (var kind in Enum.GetValues<PromptKind>())
if (Classify(kind, effectiveRoot) == PromptFileState.MatchesKnownPastDefault)
ResetToDefault(kind, effectiveRoot);
}
/// <summary>Startup sweep: moves any *.md file under the prompts root that doesn't match a known
/// <see cref="PromptKind"/> path (leftovers from a retired naming scheme) into a "_orphans" subfolder.
/// Never deletes them outright. Returns the destination paths of files it moved.</summary>
public static IReadOnlyList<string> QuarantineOrphans(string? root = null)
{
var effectiveRoot = root ?? Root;
if (!Directory.Exists(effectiveRoot)) return Array.Empty<string>();
var known = Enum.GetValues<PromptKind>()
.Select(k => PathFor(k, effectiveRoot))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var moved = new List<string>();
foreach (var file in Directory.EnumerateFiles(effectiveRoot, "*.md", SearchOption.TopDirectoryOnly))
{
if (known.Contains(file)) continue;
var orphansDir = Path.Combine(effectiveRoot, "_orphans");
Directory.CreateDirectory(orphansDir);
var dest = Path.Combine(orphansDir, Path.GetFileName(file));
if (File.Exists(dest))
dest = Path.Combine(orphansDir,
$"{Path.GetFileNameWithoutExtension(file)}-{HashOf(file)[..8]}{Path.GetExtension(file)}");
File.Move(file, dest);
moved.Add(dest);
}
return moved;
}
/// <summary>Compact, non-LCS diff (common prefix/suffix trimmed, differing middle shown +/-) between the
/// bundled default and the on-disk override, for surfacing a customization in the Files settings tab.</summary>
public static string DiffAgainstDefault(PromptKind kind, string? root = null)
{
var effectiveRoot = root ?? Root;
var defaultLines = Normalize(DefaultFor(kind)).Split('\n');
var fileLines = Normalize(ReadOrNull(kind, effectiveRoot) ?? "").Split('\n');
var prefix = 0;
while (prefix < defaultLines.Length && prefix < fileLines.Length && defaultLines[prefix] == fileLines[prefix])
prefix++;
var suffix = 0;
while (suffix < defaultLines.Length - prefix && suffix < fileLines.Length - prefix &&
defaultLines[^(suffix + 1)] == fileLines[^(suffix + 1)])
suffix++;
var sb = new StringBuilder();
for (var i = prefix; i < defaultLines.Length - suffix; i++) sb.AppendLine("- " + defaultLines[i]);
for (var i = prefix; i < fileLines.Length - suffix; i++) sb.AppendLine("+ " + fileLines[i]);
return sb.ToString().TrimEnd();
}
internal static string Normalize(string s) => s.Replace("\r\n", "\n").Trim();
internal static string HashOf(string content) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
private static string DefaultsHashPath(string root) => Path.Combine(root, ".defaults.json");
private static Dictionary<string, string> LoadDefaultHashes(string root)
{
var path = DefaultsHashPath(root);
if (!File.Exists(path)) return new();
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path)) ?? new();
}
catch (JsonException)
{
return new();
}
}
private static void SaveDefaultHashes(string root, Dictionary<string, string> hashes)
{
Directory.CreateDirectory(root);
File.WriteAllText(DefaultsHashPath(root), JsonSerializer.Serialize(hashes));
}
public static string? ReadOrNull(PromptKind kind, string? root = null)
{
var path = PathFor(kind, root ?? Root);
if (!File.Exists(path)) return null;
var content = File.ReadAllText(path).Trim();
return string.IsNullOrEmpty(content) ? null : content;
@@ -67,6 +213,7 @@ public static class PromptFiles
PromptKind.Refine => RefineDefault,
PromptKind.MergeHelper => MergeHelperDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
_ => ""
};
@@ -98,10 +245,30 @@ public static class PromptFiles
just restate the code.
- Validate only at real boundaries (user input, external APIs).
## Reading efficiently
- Locate before reading: use Grep/Glob to find the relevant spot instead of
opening files to look around.
- Read narrowly: pass `offset`/`limit` for the relevant section. Read a whole file
only when you already know you need all of it for files over ~400 lines that's
rarely the case.
- Don't re-read: check whether the content is already in context before reading it
again.
- For orientation questions ("where is X", "how does Y work", "which files touch
Z"), dispatch an exploration subagent instead of reading broadly yourself — the
file dump stays in its context, only the summary comes back. Skip the subagent
for a single targeted read or edit; the overhead isn't worth it there.
## Finishing
- Before claiming done, verify: run the build and relevant tests, confirm they
pass, and report what you ran. If you couldn't verify something, say so plainly.
- Make focused commits using the repository's existing commit-message convention.
You are on this task's own branch in its own worktree a commit here is the
deliverable, not an unrequested auto-commit; a rule against auto-committing
protects `main` and shared checkouts, neither of which is this. Still never
push, never commit on `main`, and never `git add -A` or a bare `git commit`
in a checkout other sessions share.
- Report the real outcome, including the commit SHA if you made one. Don't say
no commit was made when there was.
## Safety
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
@@ -140,7 +307,10 @@ public static class PromptFiles
- Touch as few files as possible. Do not restructure unrelated code.
- Do NOT file further improvements improvements are one layer deep.
- Verify the build and relevant tests before finishing, and report what you ran.
- Make one focused commit using the repository's commit-message convention.
- Make one focused commit in this task's own worktree using the repository's
commit-message convention that commit is the deliverable, not an unrequested auto-commit.
Report the real outcome, including the commit SHA; don't say no commit was
made when there was.
""";
private const string PlanningSystemDefault = """
@@ -267,6 +437,9 @@ public static class PromptFiles
If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for the "do not invent requirements" rule still applies.
## Handoff
Once every surviving task is enhanced, call handoff_list_handler with this session's task id and the surviving task ids, in the order you intend to run them. That opens a fresh session to carry out phases 35 with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop do not continue into phase 3 yourself.
## Phase 3 Run
Do NOT use run_task_now for a batch there is a single override slot and the second call fails with "override slot busy".
@@ -327,6 +500,21 @@ public static class PromptFiles
When every task is handled, print the summary.
""";
private const string MergeHelperHandoffDefault = """
# List handler handoff
Scope: {scope}
Repo: {repo}
A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3
for the tasks below their descriptions are already sharpened, so don't redo phases 02.
{tasks}
Start with phase 3 (run), continuing through review/merge and the summary as your
instructions describe.
""";
private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.
@@ -14,25 +14,45 @@ public sealed class AppSettingsRepository
{
var row = await _context.AppSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
if (row is not null) return row;
if (row is null)
{
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId, ModelPresets = ModelPresets.SerializeDefaults() };
_context.AppSettings.Add(row);
try
{
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
return row;
}
// First read after upgrading from a null model_presets column: persist the shipping
// defaults so the Settings UI shows real, editable rows instead of a code-only fallback.
if (row.ModelPresets is null)
row.ModelPresets = await BackfillModelPresetsAsync(ct);
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
try
{
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
return row;
}
private async Task<string> BackfillModelPresetsAsync(CancellationToken ct)
{
var defaults = ModelPresets.SerializeDefaults();
var tracked = await GetOrCreateTrackedRowAsync(ct);
if (tracked.ModelPresets is null)
{
tracked.ModelPresets = defaults;
await _context.SaveChangesAsync(ct);
}
return defaults;
}
private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
{
var row = await _context.AppSettings
@@ -52,6 +72,7 @@ public sealed class AppSettingsRepository
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
row.DefaultMaxTurns = updated.DefaultMaxTurns;
row.MaxTurnsCeiling = updated.MaxTurnsCeiling < 1 ? 1 : updated.MaxTurnsCeiling;
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
? "auto" : updated.DefaultPermissionMode;
row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions;
@@ -67,6 +88,8 @@ public sealed class AppSettingsRepository
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100);
row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100);
await _context.SaveChangesAsync(ct);
}
@@ -402,6 +402,19 @@ public sealed class TaskRepository
.SetProperty(t => t.HandlerHeadCommit, headCommit), ct);
}
// Persists the claude session id a fresh embedded ConPTY interactive task session will run
// under, written BEFORE launch so a closed/aborted session still leaves a resumable id.
public async Task SetInteractiveSessionIdAsync(
string taskId,
string? sessionId,
CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.InteractiveSessionId, sessionId), ct);
}
public async Task<TaskEntity?> FindByPlanningTokenAsync(
string token,
CancellationToken ct = default)
+33
View File
@@ -4,13 +4,18 @@ using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Windows;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Checks.Interfaces;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
using ClaudeDo.Installer.Localization;
using ClaudeDo.Localization;
using ClaudeDo.Releases;
using ClaudeDo.Installer.Pages.DiagnosePage;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.PathsPage;
using ClaudeDo.Installer.Pages.ServicePage;
using ClaudeDo.Installer.Pages.SystemCheckPage;
using ClaudeDo.Installer.Pages.UiSettingsPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using ClaudeDo.Installer.Steps;
@@ -121,12 +126,40 @@ public partial class App : Application
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallModeDetector>();
// Environment checks — stateless, so their infrastructure is shared; ClaudeCliLookup is
// rebuilt per EnvironmentCheckService instance so a re-check doesn't reuse a stale result.
sc.AddSingleton<IProcessRunner, ProcessRunnerAdapter>();
sc.AddSingleton<IProcessLauncher, ProcessLauncher>();
sc.AddSingleton<IPortOwnerResolver, NetstatPortOwnerResolver>();
sc.AddSingleton<ClaudeHelpLauncher>();
sc.AddTransient<Func<EnvironmentCheckService>>(sp => () =>
{
var processRunner = sp.GetRequiredService<IProcessRunner>();
var claudeLookup = new ClaudeCliLookup(processRunner);
return new EnvironmentCheckService(new IEnvironmentCheck[]
{
new GitCheck(processRunner),
new GitIdentityCheck(processRunner),
new PortCheck(sp.GetRequiredService<IPortOwnerResolver>()),
new WriteAccessCheck(),
new ClaudeCliCheck(claudeLookup),
new ClaudeVersionCheck(claudeLookup),
new ClaudeAuthCheck(claudeLookup),
new PermissionModeAutoCheck(claudeLookup),
});
});
// Pages
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
sc.AddSingleton<IInstallerPage, SystemCheckPageViewModel>();
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
sc.AddSingleton<IInstallerPage>(sp => new DiagnosePageViewModel(
sp.GetRequiredService<InstallContext>(),
sp.GetRequiredService<Func<EnvironmentCheckService>>(),
InstallerWorkerConfig.Load));
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so the Update pipeline
+76 -12
View File
@@ -21,20 +21,18 @@ Note: this is the one project where `System.Windows` is correct (WPF, not Avalon
3. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
The installer does **not** self-update. Each release ships a stable-named
`ClaudeDo.Installer.exe` asset (permanent URL
`…/releases/latest/download/ClaudeDo.Installer.exe`); the installer never checks for or
replaces itself on launch. The in-app "Update" button relaunches the on-disk installer to
run the app update — the installer binary itself only changes when the user downloads a
fresh copy. App-update detection is unaffected: `WriteInstallManifestStep` records
`ctx.InstalledVersion` (the release tag from `DownloadAndExtractStep`), which
`InstallModeDetector` compares against the latest tag.
`ClaudeDo.Installer.exe` asset (permanent URL `…/releases/latest/download/ClaudeDo.Installer.exe`);
the binary only changes when the user downloads a fresh copy. The in-app "Update" button relaunches
the on-disk installer to run the *app* update. App-update detection is unaffected:
`WriteInstallManifestStep` records `ctx.InstalledVersion` (the release tag from
`DownloadAndExtractStep`), which `InstallModeDetector` compares against the latest tag.
## Modes (`Core/InstallerMode.cs`)
| Mode | Condition | Window |
|---|---|---|
| `FreshInstall` | No `install.json` | Full wizard (all pages) |
| `Update` | `install.json` present + newer release available | Wizard — Welcome + Install pages only |
| `FreshInstall` | No `install.json` | Full wizard: Welcome → **SystemCheck** → Paths → Service → UiSettings → Install |
| `Update` | `install.json` present + newer release available | Wizard — Welcome + Install pages only (SystemCheck **not** shown) |
| `Config` | Current version, or Gitea API unreachable | `SettingsWindow` (settings / repair / uninstall) |
## Install Pipelines
@@ -62,7 +60,8 @@ Installer/
ConfigModels, InstallerService, UninstallRunner, PageResolver,
AutostartShortcut, ShortcutFactory, ProcessRunner, DarkTitleBar
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
Pages/ WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
Checks/ — environment preflight checks, see "Environment Checks" below
Pages/ — WelcomePage, SystemCheckPage, PathsPage, ServicePage, UiSettingsPage, InstallPage
(each: ViewModel + View.xaml)
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel)
```
@@ -76,13 +75,18 @@ claude mcp add --transport http --scope user claudedo http://127.0.0.1:{External
```
Non-fatal if `claude` CLI is missing or too old (prints the manual command). Server name: `claudedo`.
**`RegisterAutostartStep`** — creates a per-user Startup-folder shortcut `ClaudeDo Worker.lnk` (`Environment.SpecialFolder.Startup`). Also migrates away from legacy mechanisms:
**`RegisterAutostartStep`** — creates a per-user Startup-folder shortcut `ClaudeDo Worker.lnk` (`Environment.SpecialFolder.Startup`); `AutostartShortcut.Install` skips the rewrite (and reports it) when the shortcut already points at the current worker exe, so update/repair runs don't touch it needlessly. Also migrates away from legacy mechanisms, unconditionally on every run (no cached "already migrated" flag — see the comment in the step):
- Deletes legacy Windows service: `sc.exe stop/delete ClaudeDoWorker`
- Deletes legacy scheduled task: `schtasks /Delete /TN ClaudeDoWorker`
No new service or scheduled task is created. Rationale: the worker must run in the user's interactive session so Claude CLI auth works.
**`DownloadAndExtractStep`** — fetches `checksums.txt` first and only touches the install dir after the zip verifies. The zip is cached in `%TEMP%\ClaudeDo-download-cache` (ctor takes an override for tests) and reused on a retry when its SHA-256 still matches, so a failed attempt doesn't cost another full download; it is dropped after a successful install, a bad download is deleted immediately, and zips of other versions are pruned. `app\`/`worker\` are stashed to `*.bak` before extraction and restored if extraction fails.
**`DownloadAndExtractStep`** — fetches `checksums.txt` first and only touches the install dir after
the zip verifies. The zip is cached in `%TEMP%\ClaudeDo-download-cache` (ctor takes an override for
tests) and reused on a retry when its SHA-256 still matches, so a failed attempt doesn't cost
another full download. Cache is dropped after a successful install, a bad download is deleted
immediately, other versions are pruned. `app\`/`worker\` are stashed to `*.bak` before extraction
and restored if it fails.
### Gotcha: the installer must never run from inside the install dir
@@ -121,3 +125,63 @@ No new service or scheduled task is created. Rationale: the worker must run in t
| `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\ClaudeDo Worker.lnk` | Worker autostart |
The Apps & Features uninstall string and "Rerun Installer" both point at `<InstallDir>\uninstaller\ClaudeDo.Installer.exe` with no `/uninstall` flag — Config mode is detected from `install.json`.
## Environment Checks
`Checks/` holds one `IEnvironmentCheck` per concern, run in parallel by `EnvironmentCheckService.RunAllAsync`:
| Check | Severity | What it verifies |
|---|---|---|
| `GitCheck` | Error | `git` resolvable (via `ExecutableResolver`) and runs |
| `WriteAccessCheck` | Error | install dir + `%APPDATA%` (or first existing parent) are writable |
| `ClaudeCliCheck` | Error | `claude` resolvable on PATH, including npm `.cmd`/`.bat`/`.ps1` shims |
| `ClaudeVersionCheck` | Error | resolved `claude --version``ClaudeVersionCheck.MinimumVersion` (currently `2.1.220`) |
| `ClaudeAuthCheck` | Error | `claude auth status --json` reports `loggedIn: true` (never sends a prompt) |
| `GitIdentityCheck` | Warning | `git config user.name`/`user.email` are set |
| `PortCheck` | Warning | `SignalRPort`/`ExternalMcpPort` are free, or already owned by a running `ClaudeDo.Worker` |
| `PermissionModeAutoCheck` | Warning | CLI's `--help` still lists `auto` as a `--permission-mode` choice |
Each check returns a `CheckResult` with `CheckStatus` (`Ok` / `Failed` / `Unknown`). A check that
throws is caught by `EnvironmentCheckService` and turned into `Unknown`, never a crash.
**Gating rule:** `EnvironmentCheckReport.HasBlockingError` is true only when a check with
`Severity == Error` has `Status == Failed`. Warnings never block, and `Unknown` never blocks
regardless of severity (an indeterminate result — e.g. the CLI not found, so version/auth/auto-mode
can't be checked — must not strand the user; the underlying `Error`-severity check for the CLI
itself, `ClaudeCliCheck`, is what blocks in that case).
The check-row rendering and the check-run logic (busy state, summary text, Recheck command) live
in one place — `Checks/CheckListViewModel.cs` + `Checks/CheckListView.xaml` — composed by every
page that hosts a check list, not duplicated per page.
`SystemCheckPage` (`Pages/SystemCheckPage/`) hosts the check list in the **FreshInstall** wizard
only, registered via `PageResolver` at `Order = 1` (directly after `WelcomePage`); `WizardViewModel`
filters it back out in `Update` mode along with Paths/Service/UiSettings. Checks run automatically
on page entry (`LoadAsync`, guarded against double-entry). "Next" is disabled via
`IInstallerPage.BlocksNavigation` (`IsRunning || HasBlockingError`) — `WizardViewModel.CanGoNext`
subscribes to `PropertyChanged` on the current page so a live recheck can flip it back. A "Recheck"
button re-runs `EnvironmentCheckService.RunAllAsync` (disabled while already running).
**"Claude Help Me" button** (`Core/ClaudeHelpLauncher.cs`) — a second button below the shared
check-list footer, enabled only when `claude-cli` is `Ok` and `claude-auth` is not `Failed` (`Unknown`
stays enabled — an indeterminate login state shouldn't block the one feature that could help
diagnose it). `BuildReportAsync` renders all check results (Id/Severity/Status/Message table,
plus the full `Detail` of any `Failed` check) and system info (OS, `dotnet --list-runtimes`,
resolved `git`/`claude` messages, planned install dir/ports) into
`%TEMP%\claudedo-setup-diagnose.md` — English and hardcoded (an AI assistant reads it, not the
user) and deliberately excludes credentials/tokens/env-var dumps. `LaunchTerminal` then opens
`wt.exe -d %TEMP% cmd.exe /k <claude> <prompt>` (or `cmd.exe /k <claude> <prompt>` if `wt.exe`
isn't resolvable) via the injectable `IProcessLauncher`, pointing the initial prompt at that
report file. Errors from either step surface as `ClaudeHelpError` on the page, never an
exception. It reads the last report via `CheckListViewModel.LastReport`, so it re-evaluates on
every check run.
`DiagnosePage` (`Pages/DiagnosePage/`) hosts the same `CheckListView` in `SettingsWindow` (Config
mode only, `ShowInSettings = true` / `ShowInWizard = false`, `Order = 5` — after UiSettings).
Nothing here blocks navigation and checks do **not** auto-run on load — only on a "Recheck" click.
Unlike the wizard, its `InstallContext` is built from the **installed** configuration
(`InstallerWorkerConfig.Load()` for `ClaudeBin`/`SignalRPort`, the shared `InstallContext` for
`InstallDirectory`/`ExternalMcpPort`), refreshed on every `LoadAsync()` — not the wizard-default
`InstallContext` the DI container hands out, which is only populated once a page's `ApplyAsync`
(i.e. Save) runs. `DiagnosePage` intentionally has **no** "Claude Help Me" button — that one is
wizard-only.
@@ -0,0 +1,90 @@
<UserControl x:Class="ClaudeDo.Installer.Checks.CheckListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Checks"
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
d:DataContext="{d:DesignInstance local:CheckListViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Check list -->
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type local:CheckRowViewModel}">
<Border Margin="0,0,0,6" Padding="10,8"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1" CornerRadius="4">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Ellipse Grid.Column="0" Width="10" Height="10" Margin="0,0,10,0"
VerticalAlignment="Top"
Fill="{Binding StatusBrush}"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap" Margin="0,2,0,0"/>
<TextBlock Text="{Binding Hint}" FontSize="11"
Foreground="{StaticResource TextMutedBrush}"
TextWrapping="Wrap" Margin="0,4,0,0"
Visibility="{Binding Hint, Converter={StaticResource NullToCollapsedConverter}}"/>
<Button Content="{Binding HelpUrl}"
HorizontalAlignment="Left" Margin="-16,4,0,0"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource AccentLightBrush}"
Cursor="Hand"
Command="{Binding DataContext.OpenHelpUrlCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding HelpUrl}"
Visibility="{Binding HelpUrl, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Progress + summary -->
<StackPanel Grid.Row="1" Margin="0,8,0,0">
<ProgressBar IsIndeterminate="True" Margin="0,0,0,8"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisConverter}}"/>
<TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding HasBlockingError}" Value="True">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<!-- Footer -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="{loc:Tr installer.systemCheck.recheck}"
Command="{Binding RunChecksCommand}"/>
</StackPanel>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Checks;
public partial class CheckListView : UserControl
{
public CheckListView() => InitializeComponent();
}
@@ -0,0 +1,139 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Media;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Checks;
public sealed class CheckRowViewModel
{
public CheckRowViewModel(CheckResult result)
{
var loc = TrExtension.Localizer;
Status = result.Status;
Severity = result.Severity;
Title = loc?[result.TitleKey] ?? result.TitleKey;
Message = result.Message;
Hint = result.HintKey is not null ? loc?[result.HintKey] ?? result.HintKey : null;
HelpUrl = result.HelpUrl;
StatusBrush = ResolveBrush(Status, Severity);
}
public CheckStatus Status { get; }
public CheckSeverity Severity { get; }
public string Title { get; }
public string Message { get; }
public string? Hint { get; }
public string? HelpUrl { get; }
public Brush StatusBrush { get; }
private static Brush ResolveBrush(CheckStatus status, CheckSeverity severity)
{
var key = status switch
{
CheckStatus.Ok => "StatusGreenBrush",
CheckStatus.Unknown => "StatusGrayBrush",
CheckStatus.Failed when severity == CheckSeverity.Error => "StatusRedBrush",
CheckStatus.Failed => "StatusOrangeBrush",
_ => "StatusGrayBrush",
};
return Application.Current?.Resources[key] as Brush ?? Brushes.Gray;
}
}
/// <summary>
/// Shared presentation logic for the environment check list: running the check pass, tracking
/// busy/summary state, and the row collection. Composed (not subclassed) by every page that
/// hosts a check list — <see cref="Pages.SystemCheckPage.SystemCheckPageViewModel"/> and
/// <see cref="Pages.DiagnosePage.DiagnosePageViewModel"/> — so the run logic exists exactly once.
/// </summary>
public partial class CheckListViewModel : ObservableObject
{
private readonly InstallContext _context;
private readonly Func<EnvironmentCheckService> _checkServiceFactory;
public ObservableCollection<CheckRowViewModel> Rows { get; } = [];
/// <summary>Report of the most recent run, null before the first one. Hosts that need the raw
/// results (the wizard's "Claude Help Me" button) read it instead of running the checks again.</summary>
public EnvironmentCheckReport? LastReport { get; private set; }
[ObservableProperty] private bool _isRunning;
[ObservableProperty] private bool _hasRun;
[ObservableProperty] private bool _hasBlockingError;
[ObservableProperty] private string _summary = string.Empty;
public CheckListViewModel(InstallContext context, Func<EnvironmentCheckService> checkServiceFactory)
{
_context = context;
_checkServiceFactory = checkServiceFactory;
}
partial void OnIsRunningChanged(bool value) => RunChecksCommand.NotifyCanExecuteChanged();
[RelayCommand(CanExecute = nameof(CanRunChecks))]
private async Task RunChecksAsync()
{
if (IsRunning) return;
IsRunning = true;
Summary = TrExtension.Localizer?["installer.systemCheck.summary.running"] ?? "Checking your system…";
try
{
var service = _checkServiceFactory();
var report = await service.RunAllAsync(_context, CancellationToken.None);
Rows.Clear();
foreach (var result in report.Results)
Rows.Add(new CheckRowViewModel(result));
LastReport = report;
OnPropertyChanged(nameof(LastReport));
HasBlockingError = report.HasBlockingError;
Summary = BuildSummary(report);
HasRun = true;
}
finally
{
IsRunning = false;
}
}
private bool CanRunChecks() => !IsRunning;
private static string BuildSummary(EnvironmentCheckReport report)
{
var loc = TrExtension.Localizer;
var blocking = report.Results
.Where(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed)
.ToList();
if (blocking.Count > 0)
{
var names = string.Join(", ", blocking.Select(r => loc?[r.TitleKey] ?? r.TitleKey));
return loc?.Get("installer.systemCheck.summary.blocking", blocking.Count, names)
?? $"{blocking.Count} problem(s) must be fixed: {names}";
}
var warnings = report.Results.Count(r => r.Severity == CheckSeverity.Warning && r.Status == CheckStatus.Failed);
if (warnings > 0)
{
return loc?.Get("installer.systemCheck.summary.warnings", warnings)
?? $"{warnings} warning(s) found.";
}
return loc?["installer.systemCheck.summary.ok"] ?? "Everything looks good.";
}
[RelayCommand]
private static void OpenHelpUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return;
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}
@@ -0,0 +1,25 @@
namespace ClaudeDo.Installer.Checks;
public enum CheckSeverity { Warning, Error }
public enum CheckStatus { Ok, Failed, Unknown }
public sealed record CheckResult(
string Id,
CheckSeverity Severity,
CheckStatus Status,
string TitleKey,
string Message,
string? HintKey,
string? HelpUrl,
string? Detail)
{
public static CheckResult Ok(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
new(id, severity, CheckStatus.Ok, titleKey, message, HintKey: null, HelpUrl: null, detail);
public static CheckResult Fail(string id, CheckSeverity severity, string titleKey, string message, string? hintKey = null, string? helpUrl = null, string? detail = null) =>
new(id, severity, CheckStatus.Failed, titleKey, message, hintKey, helpUrl, detail);
public static CheckResult Unknown(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
new(id, severity, CheckStatus.Unknown, titleKey, message, HintKey: null, HelpUrl: null, detail);
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>
/// Checks login via `claude auth status --json` — never sends a prompt (costs tokens, can hang
/// on a usage-limited account). See docs/explore-notes/installer-preflight.md §4.
/// </summary>
public sealed class ClaudeAuthCheck : IEnvironmentCheck
{
public const string CheckId = "claude-auth";
private readonly ClaudeCliLookup _lookup;
public ClaudeAuthCheck(ClaudeCliLookup lookup)
{
_lookup = lookup;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"The claude CLI was not found — login could not be checked.");
}
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json");
var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct);
if (exitCode != 0)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"Login status could not be determined.", output);
}
var loggedIn = TryParseLoggedIn(output);
if (loggedIn is null)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"Login status could not be determined.", output);
}
return loggedIn.Value
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", "Logged in.")
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", "Not logged in.", "checks.claudeAuth.hint");
}
private static bool? TryParseLoggedIn(string output)
{
try
{
using var doc = JsonDocument.Parse(output);
if (doc.RootElement.TryGetProperty("loggedIn", out var prop) &&
prop.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
return prop.GetBoolean();
}
}
catch (JsonException)
{
// fall through to null — treated as Unknown
}
return null;
}
}
@@ -0,0 +1,54 @@
using System.IO;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>Without the claude CLI, no task can ever run — blocking.</summary>
public sealed class ClaudeCliCheck : IEnvironmentCheck
{
public const string CheckId = "claude-cli";
private readonly ClaudeCliLookup _lookup;
public ClaudeCliCheck(ClaudeCliLookup lookup)
{
_lookup = lookup;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null)
{
var searched = string.Join(", ", SearchedLocations());
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
$"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
"checks.claudeCli.hint");
}
if (lookup.ExitCode != 0)
{
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
$"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
"checks.claudeCli.hint", detail: lookup.Output);
}
var version = lookup.ParsedVersion?.ToString() ?? lookup.Output.Trim();
var detail = lookup.Resolved.IsShim
? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe."
: null;
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title", $"{lookup.Resolved.Path} — {version}", detail);
}
private IEnumerable<string> SearchedLocations()
{
var pathVar = _lookup.PathOverride ?? System.Environment.GetEnvironmentVariable("PATH") ?? "";
var pathEntries = pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return pathEntries.Concat(ExecutableResolver.FallbackDirectories());
}
}
@@ -0,0 +1,71 @@
using System.Text.RegularExpressions;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
public sealed record ClaudeCliLookupResult(
ResolvedExecutable? Resolved,
int ExitCode,
string Output,
Version? ParsedVersion);
/// <summary>
/// Resolves the claude CLI and runs `--version` at most once per install run, shared across
/// ClaudeCliCheck, ClaudeVersionCheck, ClaudeAuthCheck, and PermissionModeAutoCheck.
/// </summary>
public sealed class ClaudeCliLookup
{
private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
private readonly SemaphoreSlim _gate = new(1, 1);
private ClaudeCliLookupResult? _cached;
public ClaudeCliLookup(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
{
ProcessRunner = processRunner;
PathOverride = pathOverride;
PathExtOverride = pathExtOverride;
}
public IProcessRunner ProcessRunner { get; }
public string? PathOverride { get; }
public string? PathExtOverride { get; }
public async Task<ClaudeCliLookupResult> ResolveAsync(InstallContext ctx, CancellationToken ct)
{
if (_cached is not null) return _cached;
await _gate.WaitAsync(ct);
try
{
_cached ??= await ResolveCoreAsync(ctx, ct);
return _cached;
}
finally
{
_gate.Release();
}
}
private async Task<ClaudeCliLookupResult> ResolveCoreAsync(InstallContext ctx, CancellationToken ct)
{
var resolved = ExecutableResolver.Resolve(ctx.ClaudeBin, PathOverride, PathExtOverride);
if (resolved is null)
{
return new ClaudeCliLookupResult(null, ExitCode: -1, Output: string.Empty, ParsedVersion: null);
}
var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
var (exitCode, output) = await ProcessRunner.RunAsync(fileName, arguments, null, ct);
var parsedVersion = exitCode == 0 ? ParseVersion(output) : null;
return new ClaudeCliLookupResult(resolved, exitCode, output, parsedVersion);
}
private static Version? ParseVersion(string output) =>
VersionPattern.Match(output) is { Success: true } match && Version.TryParse(match.Value, out var version)
? version
: null;
}
@@ -0,0 +1,54 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>Gates the CLI flags ClaudeArgsBuilder relies on — blocking.</summary>
public sealed class ClaudeVersionCheck : IEnvironmentCheck
{
public const string CheckId = "claude-version";
/// <summary>
/// Newest version confirmed to work end-to-end with every flag ClaudeDo uses (--permission-mode
/// auto, --effort, --agents, --json-schema, --append-system-prompt, --output-format stream-json
/// --verbose, --resume, mcp add --transport http --scope user). A verified floor, not a proven
/// theoretical minimum — see docs/explore-notes/installer-preflight.md §3.
/// </summary>
public static readonly Version MinimumVersion = new(2, 1, 220);
private readonly ClaudeCliLookup _lookup;
public ClaudeVersionCheck(ClaudeCliLookup lookup)
{
_lookup = lookup;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
"The claude CLI was not found — version could not be checked.");
}
if (lookup.ExitCode != 0 || lookup.ParsedVersion is null)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
$"Could not parse a version from '{lookup.Output.Trim()}'.");
}
if (lookup.ParsedVersion < MinimumVersion)
{
return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title",
$"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
"checks.claudeVersion.hint");
}
return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title",
$"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
}
}
@@ -0,0 +1,37 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
public sealed record EnvironmentCheckReport(IReadOnlyList<CheckResult> Results)
{
public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed);
}
public sealed class EnvironmentCheckService
{
private readonly IReadOnlyList<IEnvironmentCheck> _checks;
public EnvironmentCheckService(IEnumerable<IEnvironmentCheck> checks)
{
_checks = checks.ToList();
}
public async Task<EnvironmentCheckReport> RunAllAsync(InstallContext ctx, CancellationToken ct)
{
var tasks = _checks.Select(check => RunSafeAsync(check, ctx, ct)).ToArray();
var results = await Task.WhenAll(tasks);
return new EnvironmentCheckReport(results);
}
private static async Task<CheckResult> RunSafeAsync(IEnvironmentCheck check, InstallContext ctx, CancellationToken ct)
{
try
{
return await check.RunAsync(ctx, ct);
}
catch (Exception ex)
{
return CheckResult.Unknown(check.Id, check.Severity, string.Empty, ex.Message, ex.Message);
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.Text.RegularExpressions;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
/// <summary>Without git, no task can run (worktrees) — blocking.</summary>
public sealed class GitCheck : IEnvironmentCheck
{
public const string CheckId = "git";
private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
private readonly IProcessRunner _processRunner;
private readonly string? _pathOverride;
private readonly string? _pathExtOverride;
public GitCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
{
_processRunner = processRunner;
_pathOverride = pathOverride;
_pathExtOverride = pathExtOverride;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
if (resolved is null)
{
return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.",
"checks.git.hint", "https://git-scm.com/downloads");
}
var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
var (exitCode, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
if (exitCode != 0)
{
return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.",
"checks.git.hint", "https://git-scm.com/downloads", output);
}
var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim();
return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}");
}
}
@@ -0,0 +1,60 @@
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
/// <summary>Trivial to fix after the fact (git config --global), and the Claude help-me button can resolve it — a warning, not a blocker.</summary>
public sealed class GitIdentityCheck : IEnvironmentCheck
{
public const string CheckId = "git-identity";
private readonly IProcessRunner _processRunner;
private readonly string? _pathOverride;
private readonly string? _pathExtOverride;
public GitIdentityCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
{
_processRunner = processRunner;
_pathOverride = pathOverride;
_pathExtOverride = pathExtOverride;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Warning;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
if (resolved is null)
{
return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title", "git was not found — identity could not be checked.");
}
var name = await ReadConfigAsync(resolved, "user.name", ct);
var email = await ReadConfigAsync(resolved, "user.email", ct);
var hasName = !string.IsNullOrWhiteSpace(name);
var hasEmail = !string.IsNullOrWhiteSpace(email);
if (hasName && hasEmail)
{
return CheckResult.Ok(Id, Severity, "checks.gitIdentity.title", $"{name} <{email}>");
}
var missing = (hasName, hasEmail) switch
{
(false, false) => "user.name, user.email",
(false, true) => "user.name",
_ => "user.email",
};
return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title", $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
}
private async Task<string> ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct)
{
var (fileName, arguments) = ProcessCommand.For(resolved, "config", "--get", key);
var (_, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
return output.Trim();
}
}
@@ -0,0 +1,10 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
public interface IEnvironmentCheck
{
string Id { get; }
CheckSeverity Severity { get; }
Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct);
}
@@ -0,0 +1,6 @@
namespace ClaudeDo.Installer.Checks.Interfaces;
public interface IPortOwnerResolver
{
Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct);
}
@@ -0,0 +1,33 @@
using System.Diagnostics;
using ClaudeDo.Installer.Checks.Interfaces;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
public sealed class NetstatPortOwnerResolver : IPortOwnerResolver
{
private readonly IProcessRunner _processRunner;
public NetstatPortOwnerResolver(IProcessRunner processRunner) => _processRunner = processRunner;
public async Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct)
{
var (exitCode, output) = await _processRunner.RunAsync("netstat.exe", "-ano -p TCP", null, ct);
if (exitCode != 0) return null;
var suffix = $":{port}";
foreach (var line in output.Split('\n'))
{
var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5) continue;
if (!parts[0].Equals("TCP", StringComparison.OrdinalIgnoreCase)) continue;
if (!parts[1].EndsWith(suffix, StringComparison.Ordinal)) continue;
if (!int.TryParse(parts[^1], out var pid)) continue;
try { return Process.GetProcessById(pid).ProcessName; }
catch { return null; }
}
return null;
}
}
@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>
/// Static-only check per docs/explore-notes/installer-preflight.md §2: real auto-mode eligibility
/// (org/model/plan) has no cheap detectable signal, so this only confirms the CLI's argument
/// parser still recognizes "auto" as a --permission-mode choice. A warning, not a blocker — a
/// too-old CLI just degrades to acceptEdits/default with more prompts.
/// </summary>
public sealed class PermissionModeAutoCheck : IEnvironmentCheck
{
public const string CheckId = "claude-permission-mode-auto";
private static readonly Regex AutoWord = new(@"\bauto\b", RegexOptions.Compiled);
private const int SearchWindow = 300;
private readonly ClaudeCliLookup _lookup;
public PermissionModeAutoCheck(ClaudeCliLookup lookup)
{
_lookup = lookup;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Warning;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null)
{
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
"The claude CLI was not found — permission-mode support could not be checked.");
}
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help");
var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct);
if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
{
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
"Could not determine whether --permission-mode auto is supported.");
}
if (AutoIsListed(output))
{
return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title",
"--permission-mode auto is recognized by this CLI.");
}
return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title",
"This claude CLI version does not list 'auto' as a --permission-mode choice.",
"checks.permissionModeAuto.hint");
}
private static bool AutoIsListed(string helpOutput)
{
var index = helpOutput.IndexOf("--permission-mode", StringComparison.Ordinal);
if (index < 0) return false;
var windowEnd = Math.Min(helpOutput.Length, index + SearchWindow);
return AutoWord.IsMatch(helpOutput[index..windowEnd]);
}
}
@@ -0,0 +1,71 @@
using System.Net;
using System.Net.Sockets;
using ClaudeDo.Installer.Checks.Interfaces;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>Both ports are configurable in the installer settings, so a conflict is only a warning.</summary>
public sealed class PortCheck : IEnvironmentCheck
{
public const string CheckId = "ports";
private readonly IPortOwnerResolver _portOwnerResolver;
public PortCheck(IPortOwnerResolver portOwnerResolver) => _portOwnerResolver = portOwnerResolver;
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Warning;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var ports = new (string Label, int Port)[]
{
("SignalR", ctx.SignalRPort),
("External MCP", ctx.ExternalMcpPort),
};
var blocked = new List<string>();
var ownWorker = new List<string>();
foreach (var (label, port) in ports)
{
if (IsFree(port)) continue;
var owner = await _portOwnerResolver.FindOwningProcessNameAsync(port, ct);
if (owner is not null && owner.Contains("ClaudeDo.Worker", StringComparison.OrdinalIgnoreCase))
{
ownWorker.Add($"{label} ({port})");
continue;
}
blocked.Add(owner is null ? $"{label} port {port} is in use." : $"{label} port {port} is in use by '{owner}'.");
}
if (blocked.Count > 0)
{
return CheckResult.Fail(Id, Severity, "checks.ports.title", string.Join(" ", blocked), "checks.ports.hint");
}
if (ownWorker.Count > 0)
{
return CheckResult.Ok(Id, Severity, "checks.ports.title", $"In use by the running ClaudeDo Worker: {string.Join(", ", ownWorker)}.");
}
return CheckResult.Ok(Id, Severity, "checks.ports.title", "Ports are available.");
}
private static bool IsFree(int port)
{
try
{
using var listener = new TcpListener(IPAddress.Loopback, port);
listener.Start();
return true;
}
catch (SocketException)
{
return false;
}
}
}
@@ -0,0 +1,20 @@
using ClaudeDo.Data.Environment;
namespace ClaudeDo.Installer.Checks;
/// <summary>Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe.</summary>
internal static class ProcessCommand
{
public static (string FileName, string Arguments) For(ResolvedExecutable resolved, params string[] args)
{
if (resolved.IsShim)
{
var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, args);
return (shim.FileName, shim.Arguments);
}
return (resolved.Path, string.Join(' ', args.Select(Quote)));
}
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
}
@@ -0,0 +1,62 @@
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>Without write access nothing can be installed — blocking.</summary>
public sealed class WriteAccessCheck : IEnvironmentCheck
{
public const string CheckId = "write-access";
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() })
{
var error = TryWrite(target);
if (error is not null)
{
return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title",
$"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
}
}
return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title", "Install directory and data directory are writable."));
}
private static string? TryWrite(string path)
{
var probeDir = FirstExistingParent(path);
var probeFile = Path.Combine(probeDir, $".claudedo-write-check-{Guid.NewGuid():N}.tmp");
try
{
File.WriteAllText(probeFile, string.Empty);
return null;
}
catch (Exception ex)
{
return ex.Message;
}
finally
{
try { File.Delete(probeFile); } catch { /* best-effort cleanup */ }
}
}
private static string FirstExistingParent(string path)
{
var current = Path.GetFullPath(path);
while (!Directory.Exists(current))
{
var parent = Path.GetDirectoryName(current);
if (string.IsNullOrEmpty(parent) || parent == current) break;
current = parent;
}
return current;
}
}
@@ -11,13 +11,26 @@ public static class AutostartShortcut
public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName);
public static void Install(string startupDir, string workerExe)
/// <summary>Creates or updates the Startup shortcut. Returns false if it already pointed at <paramref name="workerExe"/> and was left untouched.</summary>
public static bool Install(string startupDir, string workerExe)
{
Directory.CreateDirectory(startupDir);
var shortcutPath = PathIn(startupDir);
if (File.Exists(shortcutPath))
{
var existingTarget = ShortcutFactory.TryGetTarget(shortcutPath);
if (existingTarget is not null && PathsEqual(existingTarget, workerExe))
return false;
}
var workingDir = Path.GetDirectoryName(workerExe) ?? startupDir;
ShortcutFactory.CreateShortcut(PathIn(startupDir), workerExe, workingDir, "ClaudeDo background worker");
ShortcutFactory.CreateShortcut(shortcutPath, workerExe, workingDir, "ClaudeDo background worker");
return true;
}
private static bool PathsEqual(string a, string b) =>
string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
public static void Remove(string startupDir)
{
var path = PathIn(startupDir);
@@ -0,0 +1,182 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Core;
public sealed record ClaudeHelpLaunchResult(bool Success, string? ErrorMessage);
/// <summary>
/// Builds a setup-diagnose report from the environment check results and opens an interactive
/// claude session pointed at it, so a stuck user can get live help finishing setup.
/// </summary>
public sealed class ClaudeHelpLauncher
{
public const string ReportFileName = "claudedo-setup-diagnose.md";
private readonly IProcessRunner _processRunner;
private readonly IProcessLauncher _processLauncher;
private readonly string? _pathOverride;
private readonly string? _pathExtOverride;
public ClaudeHelpLauncher(
IProcessRunner processRunner,
IProcessLauncher processLauncher,
string? pathOverride = null,
string? pathExtOverride = null)
{
_processRunner = processRunner;
_processLauncher = processLauncher;
_pathOverride = pathOverride;
_pathExtOverride = pathExtOverride;
}
public async Task<string> BuildReportAsync(EnvironmentCheckReport report, InstallContext ctx, CancellationToken ct)
{
var sb = new StringBuilder();
sb.AppendLine("# ClaudeDo setup diagnose");
sb.AppendLine();
sb.AppendLine(
"ClaudeDo is a desktop task manager that runs the `claude` CLI autonomously in git " +
"worktrees. The person you're talking to is stuck partway through the setup wizard. " +
"Explain the likely cause of each failing check below in plain language, propose the " +
"concrete command(s) to fix it, and ask a clarifying question if something here is " +
"ambiguous.");
sb.AppendLine();
sb.AppendLine("## Check results");
sb.AppendLine();
sb.AppendLine("| Id | Severity | Status | Message |");
sb.AppendLine("|---|---|---|---|");
foreach (var result in report.Results)
{
sb.AppendLine($"| {result.Id} | {result.Severity} | {result.Status} | {EscapeCell(result.Message)} |");
}
sb.AppendLine();
var failedDetails = report.Results
.Where(r => r.Status == CheckStatus.Failed && !string.IsNullOrWhiteSpace(r.Detail))
.ToList();
if (failedDetails.Count > 0)
{
sb.AppendLine("## Details of failed checks");
foreach (var result in failedDetails)
{
sb.AppendLine();
sb.AppendLine($"### {result.Id}");
sb.AppendLine("```");
sb.AppendLine(result.Detail);
sb.AppendLine("```");
}
sb.AppendLine();
}
sb.AppendLine("## System info");
sb.AppendLine();
sb.AppendLine($"- Windows: {RuntimeInformation.OSDescription}");
sb.AppendLine($"- Architecture: {RuntimeInformation.OSArchitecture}");
sb.AppendLine($"- git: {FindMessage(report, GitCheck.CheckId)}");
sb.AppendLine($"- claude: {FindMessage(report, ClaudeCliCheck.CheckId)}");
sb.AppendLine($"- Planned install directory: {ctx.InstallDirectory}");
sb.AppendLine($"- Planned ports: SignalR {ctx.SignalRPort}, MCP {ctx.ExternalMcpPort}");
sb.AppendLine("- `dotnet --list-runtimes`:");
sb.AppendLine("```");
sb.AppendLine(await RunDotnetListRuntimesAsync(ct));
sb.AppendLine("```");
sb.AppendLine();
// No secrets: never include credentials.json content, tokens, or a raw environment
// variable dump here — only resolved paths and version numbers, since this file exists
// to be read by an AI assistant, not to stay private.
sb.AppendLine(
"No secrets are included above (no credentials file contents, no tokens, no raw " +
"environment variable dump) — only resolved paths and version numbers.");
sb.AppendLine();
sb.AppendLine("Please start with the blocking errors above.");
return sb.ToString();
}
public async Task<ClaudeHelpLaunchResult> LaunchAsync(EnvironmentCheckReport report, InstallContext ctx, CancellationToken ct)
{
string reportPath;
try
{
var content = await BuildReportAsync(report, ctx, ct);
reportPath = Path.Combine(Path.GetTempPath(), ReportFileName);
await File.WriteAllTextAsync(reportPath, content, ct);
}
catch (Exception ex)
{
return new ClaudeHelpLaunchResult(false, ex.Message);
}
return LaunchTerminal(reportPath, ctx);
}
public ClaudeHelpLaunchResult LaunchTerminal(string reportPath, InstallContext ctx)
{
var claude = ExecutableResolver.Resolve(ctx.ClaudeBin, _pathOverride, _pathExtOverride);
if (claude is null)
{
return new ClaudeHelpLaunchResult(false, $"'{ctx.ClaudeBin}' was not found on PATH.");
}
var tempDir = Path.GetTempPath();
var promptText = $"Lies {reportPath} und hilf mir, mein ClaudeDo-Setup zum Laufen zu bringen.";
var claudeCommand = $"{Quote(claude.Path)} {Quote(promptText)}";
var wt = ExecutableResolver.Resolve("wt", _pathOverride, _pathExtOverride);
var startInfo = wt is not null
? new ProcessStartInfo(wt.Path)
{
Arguments = $"-d {QuoteDirectory(tempDir)} cmd.exe /k {claudeCommand}",
WorkingDirectory = tempDir,
UseShellExecute = false,
}
: new ProcessStartInfo("cmd.exe")
{
Arguments = $"/k {claudeCommand}",
WorkingDirectory = tempDir,
UseShellExecute = false,
};
try
{
_processLauncher.Start(startInfo);
return new ClaudeHelpLaunchResult(true, null);
}
catch (Exception ex)
{
return new ClaudeHelpLaunchResult(false, ex.Message);
}
}
private async Task<string> RunDotnetListRuntimesAsync(CancellationToken ct)
{
try
{
var (_, output) = await _processRunner.RunAsync("dotnet", "--list-runtimes", null, ct);
return output.Trim();
}
catch (Exception ex)
{
return $"(could not run 'dotnet --list-runtimes': {ex.Message})";
}
}
private static string? FindMessage(EnvironmentCheckReport report, string checkId) =>
report.Results.FirstOrDefault(r => r.Id == checkId)?.Message;
private static string EscapeCell(string value) =>
value.Replace("|", "\\|").Replace("\r", " ").Replace("\n", " ");
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
private static string QuoteDirectory(string directory) => Quote(directory.TrimEnd('\\', '/'));
}
@@ -52,6 +52,9 @@ public sealed class InstallerWorkerConfig
[JsonPropertyName("claude_bin")]
public string ClaudeBin { get; set; } = "claude";
[JsonPropertyName("external_mcp_port")]
public int ExternalMcpPort { get; set; } = 47_822;
private static readonly JsonSerializerOptions ReadOpts = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
@@ -13,4 +13,7 @@ public interface IInstallerPage
Task LoadAsync();
Task ApplyAsync();
bool Validate();
/// <summary>True while this page wants "Next" disabled (e.g. a check run in progress or a blocking error).</summary>
bool BlocksNavigation => false;
}
@@ -0,0 +1,9 @@
using System.Diagnostics;
namespace ClaudeDo.Installer.Core.Interfaces;
/// <summary>Starts a detached process without waiting for it to exit or capturing its output.</summary>
public interface IProcessLauncher
{
void Start(ProcessStartInfo startInfo);
}
@@ -0,0 +1,6 @@
namespace ClaudeDo.Installer.Core.Interfaces;
public interface IProcessRunner
{
Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct);
}
@@ -0,0 +1,9 @@
using System.Diagnostics;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Core;
public sealed class ProcessLauncher : IProcessLauncher
{
public void Start(ProcessStartInfo startInfo) => Process.Start(startInfo);
}
@@ -0,0 +1,9 @@
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Core;
public sealed class ProcessRunnerAdapter : IProcessRunner
{
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct) =>
ProcessRunner.RunAsync(fileName, arguments, workingDirectory, progress: null, ct);
}
+42 -6
View File
@@ -6,16 +6,52 @@ namespace ClaudeDo.Installer.Core;
public static class ShortcutFactory
{
private const int SlgpRawPath = 0x4;
// Both helpers release the ShellLink RCW explicitly. IPersistFile.Load/Save keeps a handle on
// the .lnk for as long as the COM object lives, so leaving it to the GC lets a read immediately
// followed by a write to the same path fail with "used by another process" — exactly what
// AutostartShortcut.Install does when the recorded target changed.
public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
{
var link = (IShellLink)new ShellLink();
link.SetPath(targetPath);
link.SetWorkingDirectory(workingDir);
link.SetDescription(description);
link.SetIconLocation(targetPath, 0);
try
{
link.SetPath(targetPath);
link.SetWorkingDirectory(workingDir);
link.SetDescription(description);
link.SetIconLocation(targetPath, 0);
var file = (IPersistFile)link;
file.Save(shortcutPath, false);
var file = (IPersistFile)link;
file.Save(shortcutPath, false);
}
finally
{
Marshal.FinalReleaseComObject(link);
}
}
/// <summary>Reads the target path of an existing .lnk, or null if it can't be read (missing/corrupt).</summary>
public static string? TryGetTarget(string shortcutPath)
{
IShellLink? link = null;
try
{
link = (IShellLink)new ShellLink();
((IPersistFile)link).Load(shortcutPath, 0);
var sb = new StringBuilder(260);
link.GetPath(sb, sb.Capacity, IntPtr.Zero, SlgpRawPath);
var path = sb.ToString();
return path.Length == 0 ? null : path;
}
catch (Exception)
{
return null;
}
finally
{
if (link is not null) Marshal.FinalReleaseComObject(link);
}
}
[ComImport]
@@ -0,0 +1,27 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.DiagnosePage.DiagnosePageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.DiagnosePage"
xmlns:checks="clr-namespace:ClaudeDo.Installer.Checks"
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
d:DataContext="{d:DesignInstance local:DiagnosePageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="{loc:Tr installer.diagnose.title}" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="{loc:Tr installer.diagnose.subtitle}"
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
</StackPanel>
<checks:CheckListView Grid.Row="1" DataContext="{Binding Checks}"/>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.DiagnosePage;
public partial class DiagnosePageView : UserControl
{
public DiagnosePageView() => InitializeComponent();
}
@@ -0,0 +1,50 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.DiagnosePage;
/// <summary>
/// Re-runs the same environment checks as the wizard's SystemCheckPage, but against the
/// installed configuration (worker.config.json + the detected install directory) instead of
/// wizard defaults, and nothing here blocks navigation or auto-runs on load.
/// </summary>
public partial class DiagnosePageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _sharedContext;
private readonly Func<InstallerWorkerConfig> _loadWorkerConfig;
private readonly InstallContext _installedContext = new();
private DiagnosePageView? _view;
public string Title => TrExtension.Localizer?["installer.diagnose.title"] ?? "Diagnose";
public string Icon => "";
public int Order => 5;
public bool ShowInWizard => false;
public bool ShowInSettings => true;
public UserControl View => _view ??= new DiagnosePageView { DataContext = this };
public CheckListViewModel Checks { get; }
public DiagnosePageViewModel(InstallContext sharedContext, Func<EnvironmentCheckService> checkServiceFactory, Func<InstallerWorkerConfig> loadWorkerConfig)
{
_sharedContext = sharedContext;
_loadWorkerConfig = loadWorkerConfig;
Checks = new CheckListViewModel(_installedContext, checkServiceFactory);
}
public Task LoadAsync()
{
var cfg = _loadWorkerConfig();
_installedContext.InstallDirectory = _sharedContext.InstallDirectory;
_installedContext.ClaudeBin = cfg.ClaudeBin;
_installedContext.SignalRPort = cfg.SignalRPort;
_installedContext.ExternalMcpPort = _sharedContext.ExternalMcpPort;
return Task.CompletedTask;
}
public Task ApplyAsync() => Task.CompletedTask;
public bool Validate() => true;
}
@@ -58,6 +58,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
Steps.Add(new StepViewModel("Stop Worker"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Register MCP with Claude"));
Steps.Add(new StepViewModel("Start Worker"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
@@ -67,6 +68,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register MCP with Claude"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
@@ -12,7 +12,7 @@ public partial class PathsPageViewModel : ObservableObject, IInstallerPage
public string Title => TrExtension.Localizer?["installer.paths.title"] ?? "Paths";
public string Icon => "\uE8B7";
public int Order => 1;
public int Order => 2;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new PathsPageView { DataContext = this };
@@ -14,7 +14,7 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
public string Title => TrExtension.Localizer?["installer.service.title"] ?? "Service";
public string Icon => "\uE912";
public int Order => 2;
public int Order => 3;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new ServicePageView { DataContext = this };
@@ -0,0 +1,39 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.SystemCheckPage.SystemCheckPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.SystemCheckPage"
xmlns:checks="clr-namespace:ClaudeDo.Installer.Checks"
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
d:DataContext="{d:DesignInstance local:SystemCheckPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="{loc:Tr installer.systemCheck.title}" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="{loc:Tr installer.systemCheck.subtitle}"
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
</StackPanel>
<checks:CheckListView Grid.Row="1" DataContext="{Binding Checks}"/>
<!-- Wizard-only footer: "Claude Help Me" (the Recheck button lives in the shared list) -->
<StackPanel Grid.Row="2" Margin="0,8,0,0">
<TextBlock Text="{Binding ClaudeHelpError}" FontSize="12" TextWrapping="Wrap"
Foreground="{StaticResource ErrorBrush}" Margin="0,0,0,8"
Visibility="{Binding ClaudeHelpError, Converter={StaticResource NullToCollapsedConverter}}"/>
<Button Content="{loc:Tr installer.systemCheck.claudeHelp.button}"
HorizontalAlignment="Right"
ToolTip="{Binding ClaudeHelpTooltip}"
Command="{Binding StartClaudeHelpCommand}"/>
</StackPanel>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.SystemCheckPage;
public partial class SystemCheckPageView : UserControl
{
public SystemCheckPageView() => InitializeComponent();
}
@@ -0,0 +1,111 @@
using System.Collections.ObjectModel;
using System.Windows.Controls;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Pages.SystemCheckPage;
public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private readonly ClaudeHelpLauncher _claudeHelpLauncher;
private SystemCheckPageView? _view;
private bool _hasStarted;
public string Title => TrExtension.Localizer?["installer.systemCheck.title"] ?? "System Check";
public string Icon => "";
public int Order => 1;
public bool ShowInWizard => true;
public bool ShowInSettings => false;
public UserControl View => _view ??= new SystemCheckPageView { DataContext = this };
public CheckListViewModel Checks { get; }
// Pass-through to the shared check-run logic — kept so this page's UI/tests can bind
// directly on it, matching the pre-extraction API.
public ObservableCollection<CheckRowViewModel> Rows => Checks.Rows;
public bool IsRunning => Checks.IsRunning;
public bool HasRun => Checks.HasRun;
public bool HasBlockingError => Checks.HasBlockingError;
public string Summary => Checks.Summary;
public IAsyncRelayCommand RunChecksCommand => Checks.RunChecksCommand;
public bool BlocksNavigation => Checks.IsRunning || Checks.HasBlockingError;
[ObservableProperty] private string? _claudeHelpError;
public bool ClaudeCliOk =>
Checks.LastReport?.Results.FirstOrDefault(r => r.Id == ClaudeCliCheck.CheckId)?.Status == CheckStatus.Ok;
public bool ClaudeAuthFailed =>
Checks.LastReport?.Results.FirstOrDefault(r => r.Id == ClaudeAuthCheck.CheckId)?.Status == CheckStatus.Failed;
public bool CanStartClaudeHelp => ClaudeCliOk && !ClaudeAuthFailed;
public string ClaudeHelpTooltip
{
get
{
var loc = TrExtension.Localizer;
if (!ClaudeCliOk)
return loc?["installer.systemCheck.claudeHelp.tooltip.cliMissing"] ?? "The Claude CLI was not found.";
if (ClaudeAuthFailed)
return loc?["installer.systemCheck.claudeHelp.tooltip.notLoggedIn"] ?? "Claude is not logged in.";
return loc?["installer.systemCheck.claudeHelp.tooltip.ready"]
?? "Start an interactive Claude session to help troubleshoot your setup.";
}
}
public SystemCheckPageViewModel(
InstallContext context,
Func<EnvironmentCheckService> checkServiceFactory,
ClaudeHelpLauncher claudeHelpLauncher)
{
_context = context;
_claudeHelpLauncher = claudeHelpLauncher;
Checks = new CheckListViewModel(context, checkServiceFactory);
// The wizard listens for PropertyChanged on this page to re-evaluate "Next" — bubble
// any change from the composed check list up so a live recheck can flip it back, and so
// the Claude-Help gating re-reads the fresh report.
Checks.PropertyChanged += (_, _) =>
{
OnPropertyChanged(nameof(BlocksNavigation));
OnPropertyChanged(nameof(ClaudeCliOk));
OnPropertyChanged(nameof(ClaudeAuthFailed));
OnPropertyChanged(nameof(CanStartClaudeHelp));
OnPropertyChanged(nameof(ClaudeHelpTooltip));
StartClaudeHelpCommand.NotifyCanExecuteChanged();
};
}
public Task LoadAsync()
{
if (!_hasStarted)
{
_hasStarted = true;
_ = Checks.RunChecksCommand.ExecuteAsync(null);
}
return Task.CompletedTask;
}
public Task ApplyAsync() => Task.CompletedTask;
public bool Validate() => !HasBlockingError;
[RelayCommand(CanExecute = nameof(CanStartClaudeHelp))]
private async Task StartClaudeHelpAsync()
{
if (Checks.LastReport is null) return;
ClaudeHelpError = null;
var result = await _claudeHelpLauncher.LaunchAsync(Checks.LastReport, _context, CancellationToken.None);
if (!result.Success)
{
ClaudeHelpError = TrExtension.Localizer?.Get("installer.systemCheck.claudeHelp.error", result.ErrorMessage ?? "")
?? $"Could not start the Claude session: {result.ErrorMessage}";
}
}
}
@@ -12,7 +12,7 @@ public partial class UiSettingsPageViewModel : ObservableObject, IInstallerPage
public string Title => TrExtension.Localizer?["installer.uiSettings.title"] ?? "UI Settings";
public string Icon => "\uE771";
public int Order => 3;
public int Order => 4;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new UiSettingsPageView { DataContext = this };
@@ -16,6 +16,11 @@ public sealed class RegisterAutostartStep : IInstallStep
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// Legacy service/task cleanup below runs unconditionally on every install/update/repair,
// even though it's a no-op once migrated. A cached "already migrated" flag could go stale
// (e.g. a user re-adds the legacy service) and strand them with it still running; two
// extra process starts per run is the price for that migration safety net.
// 1) Migrate away the legacy Windows service if present.
progress.Report("Checking for legacy worker service...");
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
@@ -38,10 +43,11 @@ public sealed class RegisterAutostartStep : IInstallStep
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{LegacyTaskName}\" /F", null, progress, ct);
// 3) Register per-user autostart via a Startup-folder shortcut.
progress.Report("Creating Startup shortcut...");
progress.Report("Checking Startup shortcut...");
try
{
AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe);
var created = AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe);
progress.Report(created ? "Startup shortcut created." : "Startup shortcut already up to date.");
}
catch (Exception ex)
{
@@ -5,9 +5,23 @@ namespace ClaudeDo.Installer.Steps;
public sealed class RegisterMcpStep : IInstallStep
{
private const string ServerName = "claudedo";
private readonly Func<InstallerWorkerConfig> _loadWorkerConfig;
public RegisterMcpStep(Func<InstallerWorkerConfig>? loadWorkerConfig = null)
{
_loadWorkerConfig = loadWorkerConfig ?? InstallerWorkerConfig.Load;
}
public string Name => "Register MCP with Claude";
// Resolves the URL from the persisted worker.config.json rather than ctx.ExternalMcpPort:
// an Update run never repopulates ctx from the existing installation, so ctx would still
// hold the wizard default (47822) even when the installed config has a different port.
// Returns null when the port is 0 — WorkerConfig treats that as "external listener off",
// so registering a URL against it would just point Claude at nothing.
public static string? ResolveUrl(InstallerWorkerConfig cfg) =>
cfg.ExternalMcpPort == 0 ? null : $"http://127.0.0.1:{cfg.ExternalMcpPort}/mcp";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (!ctx.RegisterMcpWithClaude)
@@ -16,7 +30,12 @@ public sealed class RegisterMcpStep : IInstallStep
return StepResult.Ok();
}
var url = $"http://127.0.0.1:{ctx.ExternalMcpPort}/mcp";
var url = ResolveUrl(_loadWorkerConfig());
if (url is null)
{
progress.Report("Skipped (external_mcp_port is 0 — the external MCP listener is disabled).");
return StepResult.Ok();
}
// Drop any prior registration first so a re-run (e.g. update, changed port)
// overwrites cleanly instead of erroring on a duplicate name.
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Linq;
using System.Windows;
using ClaudeDo.Installer.Core;
@@ -32,10 +33,12 @@ public partial class WizardViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(IsLastPage))]
[NotifyPropertyChangedFor(nameof(NextButtonText))]
[NotifyPropertyChangedFor(nameof(CurrentPage))]
[NotifyPropertyChangedFor(nameof(CanGoNext))]
private int _currentPageIndex;
public IInstallerPage CurrentPage => Pages[CurrentPageIndex];
public bool CanGoBack => CurrentPageIndex > 0;
public bool CanGoNext => !CurrentPage.BlocksNavigation;
public bool IsLastPage => CurrentPageIndex == Pages.Count - 1;
public string NextButtonText => IsLastPage
? (_localizer["installer.nav.install"])
@@ -58,6 +61,14 @@ public partial class WizardViewModel : ObservableObject
|| p is InstallPageViewModel).ToList()
: all;
// A page's blocking state (e.g. a running/failed system check) can change while it's
// displayed; re-evaluate CanGoNext whenever any page raises a property change.
foreach (var page in Pages)
{
if (page is INotifyPropertyChanged notifying)
notifying.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CanGoNext));
}
if (Pages.Count > 0)
_ = InitAsync();
}
@@ -100,6 +100,7 @@
<Button Grid.Column="2" Content="{Binding NextButtonText}"
Command="{Binding GoNextCommand}"
IsEnabled="{Binding CanGoNext}"
Style="{StaticResource AccentButton}"
MinWidth="100"/>
</Grid>
+131 -30
View File
@@ -18,6 +18,9 @@
"effort": "Effort",
"modelPresets": "Vorgaben pro Modell",
"modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.",
"maxTurnsCeiling": "Max. Durchläufe Obergrenze",
"maxTurnsCeilingHint": "Harte Obergrenze, auf die jeder aufgelöste Max-Durchläufe-Wert geklemmt wird — Task-, Listen- und Modell-Einstellungen können sie nicht überschreiten.",
"maxTurnsCeilingInvalid": "Die Obergrenze für Max. Durchläufe muss mindestens 1 sein.",
"permission": "Berechtigung",
"maxParallelExecutions": "Max. parallele Ausführungen",
"maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.",
@@ -62,7 +65,10 @@
"retryPrompt": "Wiederholung",
"dailyPrepPrompt": "Tagesplanung",
"weeklyReportPrompt": "Wochenbericht",
"openInEditor": "Im Editor öffnen"
"viewPrompt": "Ansehen",
"customizedSection": "ANGEPASST",
"customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.",
"resetToDefault": "Auf Standard zurücksetzen"
},
"prime": {
"description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.",
@@ -103,6 +109,7 @@
"overrideBadge": "überschrieben",
"resetToInherited": "Auf geerbt zurücksetzen"
},
"turnsCeilingHint": "Läufe sind auf {0} Durchläufe gedeckelt — dieser Wert wird geklemmt.",
"agentEditor": {
"model": "Modell",
"maxTurns": "Max. Durchläufe",
@@ -126,6 +133,7 @@
"tasks": {
"showCompletedTip": "Abgeschlossene anzeigen",
"letClaudeTip": "Claude machen lassen",
"quickClaudeTip": "Schnelle Claude-Sitzung",
"listSettingsTip": "Listeneinstellungen",
"addPlaceholder": "Aufgabe hinzufügen…",
"enterKey": "ENTER",
@@ -141,7 +149,7 @@
"ctxMarkAs": "Markieren als",
"ctxMarkDone": "Erledigt",
"ctxMarkCancelled": "Abgebrochen",
"ctxOpenConPtySession": "ConPTY-Sitzung öffnen",
"ctxOpenConPtySession": "Interaktive Sitzung öffnen",
"ctxOpenPlanningSession": "Planungssitzung öffnen",
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
"ctxFinalizePlanningSession": "Plan finalisieren",
@@ -186,11 +194,9 @@
"settingsTip": "Einstellungen",
"smartListsLabel": "INTELLIGENTE LISTEN",
"myListsLabel": "MEINE LISTEN",
"contextSettings": "Einstellungen...",
"contextWorktrees": "Worktrees…",
"contextOpenExplorer": "Im Explorer öffnen",
"contextOpenTerminal": "Im Terminal öffnen",
"contextLetClaude": "Claude machen lassen",
"newList": "Neue Liste",
"addReposTip": "Repos als Listen hinzufügen"
},
@@ -243,21 +249,7 @@
}
},
"agent": {
"stopTip": "Agent stoppen",
"sendToQueue": "In Warteschlange einreihen",
"sendToQueueTip": "Diese Aufgabe einreihen, damit der Worker sie übernimmt",
"removeFromQueue": "Aus Warteschlange entfernen",
"removeFromQueueTip": "Diese Aufgabe wieder aus der Warteschlange nehmen",
"worktreeLabel": "WORKTREE",
"copyPathTip": "Pfad kopieren",
"diffLabel": "DIFF",
"openDiff": "Diff öffnen",
"worktreeBtn": "Worktree",
"openWorktreeTip": "Worktree im Datei-Explorer öffnen",
"continue": "Fortsetzen",
"continueTip": "Die letzte Sitzung fortsetzen und weitermachen",
"resetAndRetry": "Zurücksetzen & erneut versuchen",
"resetAndRetryTip": "Den Worktree verwerfen und die Aufgabe erneut einreihen, um von vorn zu beginnen"
"openWorktreeTip": "Worktree im Datei-Explorer öffnen"
},
"notes": {
"today": "Heute",
@@ -284,14 +276,17 @@
"focusMode": "Fokus",
"overviewMode": "Übersicht",
"closeSession": "Sitzung schließen",
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
"conptyStarting": "Sitzung wird gestartet…",
"mergeHelperTitle": "Merge-Helfer",
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
"mergeHelperTaskTitle": "Listen-Handler: {0}",
"mergeHelperTaskDescriptionHeader": "Von diesem Lauf bearbeitete Tasks:",
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
"submitForReview": "Zum Review einreichen",
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
"retry": "Erneut versuchen",
"retryTip": "Diese Sitzung erneut starten",
"planningTitleSuffix": " (Planung)",
"question": {
"title": "Claude fragt nach",
@@ -306,6 +301,10 @@
"refresh": "Aktualisieren",
"empty": "Keine Logs in den letzten 30 Minuten.",
"count": "{0} Einträge",
"copyLast": "Letzte 40 kopieren",
"copyLastTooltip": "Kopiert die letzten 40 sichtbaren Zeilen in die Zwischenablage",
"copied": "{0} Zeilen kopiert",
"copyFailed": "Kopieren fehlgeschlagen",
"footerHint": "logs",
"openTooltip": "Aktuelle Worker-Logs anzeigen"
},
@@ -427,9 +426,12 @@
"title": "USAGE MONITOR",
"windowTitle": "Usage Monitor",
"noGauges": "Noch keine Nutzungslimits gemeldet.",
"refresh": "Jetzt aktualisieren",
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"staleFormat": "Werte veraltet (Stand {0})",
"staleGateHint": "Das Gate greift in diesem Zustand nicht.",
"gateBlockedFormat": "Queue pausiert — {0}",
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
"resetIn": "Reset in {0}",
"gaugeSession": "Session (5 Std.)",
"gaugeWeeklyAll": "Woche (alle Modelle)",
@@ -474,6 +476,26 @@
"registerMcp": "MCP-Server bei Claude registrieren",
"registerMcpHint": "Führt 'claude mcp add' aus, damit Claude deine ClaudeDo-Aufgaben sehen und verwalten kann. Du kannst dies später ändern."
},
"systemCheck": {
"title": "Systemprüfung",
"subtitle": "ClaudeDo prüft dein System vor der Installation.",
"recheck": "Erneut prüfen",
"summary": {
"running": "System wird geprüft…",
"ok": "Alles in Ordnung.",
"warnings": "{0} Warnung(en) gefunden.",
"blocking": "{0} Problem(e) müssen behoben werden: {1}"
},
"claudeHelp": {
"button": "Claude Help Me",
"error": "Claude-Session konnte nicht gestartet werden: {0}",
"tooltip": {
"cliMissing": "Claude CLI wurde nicht gefunden — installiere sie zuerst (siehe Hinweis zur Claude CLI oben).",
"notLoggedIn": "Claude ist nicht eingeloggt — führe einmal 'claude' aus und schließe den Login-Vorgang ab.",
"ready": "Öffnet eine interaktive Claude-Session, die dir beim Einrichten hilft."
}
}
},
"paths": {
"title": "Datenpfade",
"subtitle": "Lege fest, wo ClaudeDo seine Daten speichert.",
@@ -505,17 +527,50 @@
"subtitle": "Klicke auf Installieren, um ClaudeDo zu erstellen und bereitzustellen.",
"launch": "ClaudeDo starten"
},
"diagnose": {
"title": "Diagnose",
"subtitle": "Führt die Systemprüfungen erneut aus, gegen deine installierte Konfiguration."
},
"settings": {
"removeUserData": "Benutzerdaten entfernen (Aufgaben, Logs, Konfigurationen in ~/.todo-app)",
"uninstall": "Deinstallieren",
"repair": "Reparieren",
"save": "Speichern",
"close": "Schließen"
}
},
"checks": {
"git": {
"title": "Git",
"hint": "Installiere Git und stelle sicher, dass es im PATH liegt."
},
"selfUpdate": {
"heading": "Ein neuerer Installer ist verfügbar",
"update": "Aktualisieren",
"continueAnyway": "Trotzdem fortfahren"
"gitIdentity": {
"title": "Git-Identität",
"hint": "Lege deine Git-Identität fest: git config --global user.name \"...\" und user.email \"...\"."
},
"ports": {
"title": "Ports",
"hint": "Gib den Port frei oder ändere ihn nach der Installation auf der Worker-Seite."
},
"writeAccess": {
"title": "Schreibzugriff",
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer als Administrator."
},
"claudeCli": {
"title": "Claude CLI",
"hint": "Installiere die Claude CLI und stelle sicher, dass sie im PATH liegt."
},
"claudeVersion": {
"title": "Claude-CLI-Version",
"hint": "Aktualisiere die Claude CLI auf eine neuere Version."
},
"claudeAuth": {
"title": "Claude-Anmeldung",
"hint": "Führe 'claude auth login' aus, um dich anzumelden."
},
"permissionModeAuto": {
"title": "Berechtigungsmodus",
"hint": "Aktualisiere die Claude CLI auf eine Version, die --permission-mode auto unterstützt."
}
},
"planning": {
@@ -576,6 +631,13 @@
"available": "Update verfügbar: v",
"updateNow": "Jetzt aktualisieren",
"dismiss": "Ausblenden"
},
"staleWorker": {
"message": "Der Worker läuft auf einem älteren Stand als der gemergte main-Branch dieses Repos — Neustart nötig, damit gemergte Änderungen wirken."
},
"externalMerge": {
"banner": "Eine Claude-Session löst gerade einen Merge-Konflikt auf — bitte keine Dateien im Repository bearbeiten.",
"open": "Resolver öffnen"
}
},
"vm": {
@@ -585,22 +647,26 @@
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar." },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"usageMonitor": { "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
"usageMonitor": {
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}"
},
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
"listSettings": { "untitled": "Unbenannt" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" },
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
},
"usage": {
"pill": {
@@ -615,7 +681,42 @@
"durationMinutes": "{0} m",
"blockedReason": "Blockiert: {0}",
"stale": "veraltet (Stand {0})",
"lastError": "Letzter Fehler: {0}"
"lastError": "Letzter Fehler: {0}",
"throttled": "Gedrosselt: {0}/{1} Slots ({2})"
}
},
"checks": {
"git": {
"title": "Git",
"hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu."
},
"gitIdentity": {
"title": "Git-Identität",
"hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\""
},
"ports": {
"title": "Ports",
"hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess."
},
"writeAccess": {
"title": "Schreibzugriff",
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)."
},
"claudeCli": {
"title": "Claude CLI",
"hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift."
},
"claudeVersion": {
"title": "Claude-CLI-Version",
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code"
},
"claudeAuth": {
"title": "Claude-CLI-Login",
"hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab."
},
"permissionModeAuto": {
"title": "Unterstützung für --permission-mode auto",
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen."
}
}
}
+132 -31
View File
@@ -18,6 +18,9 @@
"effort": "Effort",
"modelPresets": "Per-model defaults",
"modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.",
"maxTurnsCeiling": "Max turns ceiling",
"maxTurnsCeilingHint": "Hard cap every resolved max-turns value is clamped to — task, list, and per-model settings can't exceed this.",
"maxTurnsCeilingInvalid": "Max turns ceiling must be at least 1.",
"permission": "Permission",
"maxParallelExecutions": "Max parallel executions",
"maxParallelExecutionsHint": "How many queued tasks the worker runs at once.",
@@ -62,7 +65,10 @@
"retryPrompt": "Retry",
"dailyPrepPrompt": "Daily prep",
"weeklyReportPrompt": "Weekly report",
"openInEditor": "Open in editor"
"viewPrompt": "View",
"customizedSection": "CUSTOMIZED",
"customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.",
"resetToDefault": "Reset to default"
},
"prime": {
"description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.",
@@ -103,6 +109,7 @@
"overrideBadge": "override",
"resetToInherited": "Reset to inherited"
},
"turnsCeilingHint": "Runs are capped at {0} turns — this value will be clamped.",
"agentEditor": {
"model": "Model",
"maxTurns": "Max turns",
@@ -126,6 +133,7 @@
"tasks": {
"showCompletedTip": "Show completed",
"letClaudeTip": "Let Claude handle it",
"quickClaudeTip": "Quick Claude session",
"listSettingsTip": "List settings",
"addPlaceholder": "Add a task…",
"enterKey": "ENTER",
@@ -141,7 +149,7 @@
"ctxMarkAs": "Mark as",
"ctxMarkDone": "Done",
"ctxMarkCancelled": "Cancelled",
"ctxOpenConPtySession": "Open ConPTY session",
"ctxOpenConPtySession": "Open interactive session",
"ctxOpenPlanningSession": "Open planning Session",
"ctxResumePlanningSession": "Resume planning Session",
"ctxFinalizePlanningSession": "Finalize plan",
@@ -186,11 +194,9 @@
"settingsTip": "Settings",
"smartListsLabel": "SMART LISTS",
"myListsLabel": "MY LISTS",
"contextSettings": "Settings...",
"contextWorktrees": "Worktrees…",
"contextOpenExplorer": "Open in Explorer",
"contextOpenTerminal": "Open in Terminal",
"contextLetClaude": "Let Claude handle it",
"newList": "New list",
"addReposTip": "Add repos as lists"
},
@@ -243,21 +249,7 @@
}
},
"agent": {
"stopTip": "Stop agent",
"sendToQueue": "Send to queue",
"sendToQueueTip": "Queue this task for the worker to pick up",
"removeFromQueue": "Remove from queue",
"removeFromQueueTip": "Take this task back out of the queue",
"worktreeLabel": "WORKTREE",
"copyPathTip": "Copy path",
"diffLabel": "DIFF",
"openDiff": "Open diff",
"worktreeBtn": "Worktree",
"openWorktreeTip": "Open worktree in file explorer",
"continue": "Continue",
"continueTip": "Resume the last session and keep going",
"resetAndRetry": "Reset & retry",
"resetAndRetryTip": "Discard the worktree and re-queue the task to run from scratch"
"openWorktreeTip": "Open worktree in file explorer"
},
"notes": {
"today": "Today",
@@ -284,14 +276,17 @@
"focusMode": "Focus",
"overviewMode": "Overview",
"closeSession": "Close session",
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
"conptyStarting": "Starting session…",
"mergeHelperTitle": "Merge Helper",
"mergeHelperHandoffTitleSuffix": " (Handoff)",
"mergeHelperTaskTitle": "List handler: {0}",
"mergeHelperTaskDescriptionHeader": "Tasks handled by this run:",
"submitForReviewFailed": "Couldn't submit for review: {0}",
"submitForReview": "Submit for review",
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
"retry": "Retry",
"retryTip": "Try launching this session again",
"planningTitleSuffix": " (Planning)",
"question": {
"title": "Claude is asking",
@@ -307,7 +302,11 @@
"empty": "No logs in the last 30 minutes.",
"count": "{0} entries",
"footerHint": "logs",
"openTooltip": "View recent worker logs"
"openTooltip": "View recent worker logs",
"copyLast": "Copy last 40",
"copyLastTooltip": "Copy the last 40 visible lines to the clipboard",
"copied": "{0} lines copied",
"copyFailed": "Copy failed"
},
"about": {
"title": "ABOUT",
@@ -427,9 +426,12 @@
"title": "USAGE MONITOR",
"windowTitle": "Usage Monitor",
"noGauges": "No usage limits reported yet.",
"refresh": "Refresh now",
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
"staleFormat": "Values stale (as of {0})",
"staleGateHint": "The gate does not apply while values are stale.",
"gateBlockedFormat": "Queue paused — {0}",
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
"resetIn": "Reset in {0}",
"gaugeSession": "Session (5h)",
"gaugeWeeklyAll": "Week (all models)",
@@ -474,6 +476,26 @@
"registerMcp": "Register MCP server with Claude",
"registerMcpHint": "Runs 'claude mcp add' so Claude can view and manage your ClaudeDo tasks. You can change this later."
},
"systemCheck": {
"title": "System Check",
"subtitle": "ClaudeDo checks your system before installing.",
"recheck": "Recheck",
"summary": {
"running": "Checking your system…",
"ok": "Everything looks good.",
"warnings": "{0} warning(s) found.",
"blocking": "{0} problem(s) must be fixed: {1}"
},
"claudeHelp": {
"button": "Claude Help Me",
"error": "Could not start the Claude session: {0}",
"tooltip": {
"cliMissing": "The Claude CLI was not found — install it first (see the Claude CLI hint above).",
"notLoggedIn": "Claude is not logged in — run 'claude' once and complete the login flow.",
"ready": "Open an interactive Claude session to help fix your setup."
}
}
},
"paths": {
"title": "Data Paths",
"subtitle": "Configure where ClaudeDo stores its data.",
@@ -505,17 +527,50 @@
"subtitle": "Click Install to build and deploy ClaudeDo.",
"launch": "Launch ClaudeDo"
},
"diagnose": {
"title": "Diagnose",
"subtitle": "Re-run the environment checks against your installed configuration."
},
"settings": {
"removeUserData": "Remove user data (tasks, logs, configs in ~/.todo-app)",
"uninstall": "Uninstall",
"repair": "Repair",
"save": "Save",
"close": "Close"
}
},
"checks": {
"git": {
"title": "Git",
"hint": "Install Git and make sure it is on PATH."
},
"selfUpdate": {
"heading": "A newer installer is available",
"update": "Update",
"continueAnyway": "Continue anyway"
"gitIdentity": {
"title": "Git Identity",
"hint": "Set your git identity: git config --global user.name \"...\" and user.email \"...\"."
},
"ports": {
"title": "Ports",
"hint": "Free the port, or change it on the Worker page after install."
},
"writeAccess": {
"title": "Write Access",
"hint": "Choose a different install directory, or run the installer as administrator."
},
"claudeCli": {
"title": "Claude CLI",
"hint": "Install the Claude CLI and make sure it is on PATH."
},
"claudeVersion": {
"title": "Claude CLI Version",
"hint": "Update the Claude CLI to a newer version."
},
"claudeAuth": {
"title": "Claude Login",
"hint": "Run 'claude auth login' to sign in."
},
"permissionModeAuto": {
"title": "Permission Mode",
"hint": "Update the Claude CLI to a version that supports --permission-mode auto."
}
},
"planning": {
@@ -576,6 +631,13 @@
"available": "Update available: v",
"updateNow": "Update now",
"dismiss": "Dismiss"
},
"staleWorker": {
"message": "The worker is running an older build than this repo's merged main — restart it so your merged changes take effect."
},
"externalMerge": {
"banner": "A Claude session is resolving a merge conflict — don't edit files in the repository.",
"open": "Open resolver"
}
},
"vm": {
@@ -585,22 +647,26 @@
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available." },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"usageMonitor": { "loadFailed": "Couldn't load usage data: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
"usageMonitor": {
"loadFailed": "Couldn't load usage data: {0}",
"refreshFailed": "Couldn't refresh usage: {0}"
},
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
"listSettings": { "untitled": "Untitled" },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" },
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
},
"usage": {
"pill": {
@@ -615,7 +681,42 @@
"durationMinutes": "{0} m",
"blockedReason": "Blocked: {0}",
"stale": "stale (as of {0})",
"lastError": "Last error: {0}"
"lastError": "Last error: {0}",
"throttled": "Throttled: {0}/{1} slots ({2})"
}
},
"checks": {
"git": {
"title": "Git",
"hint": "Install Git from https://git-scm.com/downloads, then restart the installer."
},
"gitIdentity": {
"title": "Git identity",
"hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\""
},
"ports": {
"title": "Ports",
"hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them."
},
"writeAccess": {
"title": "Write access",
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)."
},
"claudeCli": {
"title": "Claude CLI",
"hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect."
},
"claudeVersion": {
"title": "Claude CLI version",
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code"
},
"claudeAuth": {
"title": "Claude CLI login",
"hint": "Run 'claude' once and complete the login flow."
},
"permissionModeAuto": {
"title": "--permission-mode auto support",
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts."
}
}
}
+68 -35
View File
@@ -2,17 +2,22 @@
Avalonia UI layer: views, viewmodels, converters, and the SignalR client.
Deeper detail: [review-merge](../../docs/explore-notes/review-merge.md) (diff stack + conflict
resolver) · [conpty-sessions](../../docs/explore-notes/conpty-sessions.md) (Mission Control
tiles) · [usage-monitoring](../../docs/explore-notes/usage-monitoring.md) (usage pill + modal).
## Pattern
MVVM with CommunityToolkit.Mvvm source generators:
- `[ObservableProperty]` for bindable properties
- `[RelayCommand]` for commands (supports async and CanExecute)
- `[ObservableProperty]` for bindable properties, `[RelayCommand]` for commands
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
- All views use compiled bindings (`x:DataType`)
## Layout: Islands
`MainWindow` hosts three "islands" (lists | tasks | details). There is no MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is **IslandsShellViewModel**, and task/list editing happens inline in the islands.
`MainWindow` hosts three "islands" (lists | tasks | details). There is **no**
MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is
`IslandsShellViewModel`, and task/list editing happens inline in the islands.
```
ViewModels/
@@ -23,52 +28,80 @@ ViewModels/
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, MergeHelperSelection,
RepoImport, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
WorkerConnection, WorktreesOverview, UnifiedDiffParser
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, AgentConfigEditor
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
(component styles + the filled icon geometry library)
Conflicts/ — ConflictResolverViewModel + ConflictModels
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge,
AgentConfigEditor
Design/ — Tokens.axaml (design tokens; merged before styles)
+ IslandStyles.axaml (component styles + the filled icon geometry library)
```
## ViewModels
## Core ViewModels
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `ToggleManual` (per-task manual flag) and `SyncInteractiveSessions` (mirrors Mission Control's open ConPTY panes onto the rows); MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards) — the ROADBLOCK card also has a reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) that resumes the session via the same `ContinueTaskAsync` transport as `ContinueCommand` but with the user's own text instead of the fixed re-run prompt; failures raise `ErrorReported`, wired by the shell into `FlashFooterError`, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
- **UsagePillViewModel** — one shared instance backs the `UsagePill` control hosted in both the footer and the Mission Control header; loads via `GetUsageSnapshotAsync` and updates live off `IWorkerClient.UsageUpdatedEvent`; derives display text, tooltip, and dot state (normal/warn/stale/blocked, mutually exclusive priority blocked > stale > warn > normal) from the shared `UsageSnapshotDto`.
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets``ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, `VerifyCommand` (optional post-merge verify gate, own field/section — not part of `AgentConfigEditorViewModel`), delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync(verifyCommand)`, since both fields land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks 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 first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`), `UsageMonitorModalViewModel` (opened from the usage pill; renders one gauge per row in `UsageSnapshotDto.Limits`**dynamic**, since the fixed `seven_day_opus`/`seven_day_sonnet`-style buckets the raw Anthropic API can return are plan-dependent and come back `null` on plans that don't have them, so a fixed gauge layout would break; also shows model usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage (`GetTaskUsageAsync`) tables over a 7d/30d preset or custom date range).
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — ``/`` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
- The ROADBLOCK card's reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) resumes the session over the same `ContinueTaskAsync` transport as `ContinueCommand`, but with the user's own text.
- Attachments write directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`; `ComposedPreview` includes attachment paths.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs. Task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`, plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping jumps to that Mission Control pane). List row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`.
- **NotesEditorViewModel** — day navigator + bullet CRUD via `INotesApi`.
- **UsagePillViewModel** — one shared instance backs the `UsagePill` in both the footer and the Mission Control header → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
## Modal VMs
| VM | Notes |
|---|---|
| `SettingsModalViewModel` | Four tabs: General, Worktrees, Files (prompt paths), Prime Claude. General hosts the per-model preset table (`ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field. |
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. |
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a
warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
## Diff & Conflicts
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
read-only viewer, Files and Planning modes) + `DiffLinesView`.
`ConflictResolverViewModel` is an in-app Rider-style 3-pane AvaloniaEdit merge editor for both
single-task and planning unit-merge conflicts. Full detail →
[review-merge](../../docs/explore-notes/review-merge.md).
## Services
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, refine), embedded ConPTY launch specs (`GetInteractiveLaunchSpecAsync`/`GetAdHocLaunchSpecAsync`), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`), usage monitoring (`GetUsageSnapshotAsync -> UsageSnapshotDto?`, `GetModelUsageAsync(from, to)`, `GetTaskUsageAsync(from, to)`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log, `UsageUpdatedEvent`). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
- **INotesApi** / **WorkerNotesApi** — daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`).
- **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner).
- **WorkerClient / IWorkerClient** — SignalR client on `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface **tracks `WorkerHub`** — treat `src/ClaudeDo.Worker/Hub/WorkerHub.cs` as the canonical method list rather than duplicating it here. Events mirror `HubBroadcaster`. Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
- **INotesApi / WorkerNotesApi** — daily-note CRUD; UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **IPrimeScheduleApi** — prime-schedule CRUD.
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
## Converters
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
In `Converters/` — grep rather than list: log-level brush, dot brush,
status equality, icon key, strike/italic/opacity toggles, null→bool,
uppercase.
## Dialog Pattern
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog sets the result on save/cancel, and the caller awaits the TCS.
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
sets the result on save/cancel, the caller awaits the TCS.
## Notes
## Gotchas
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
- "Run Now" CanExecute re-evaluates when worker connection state changes
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
- Window key bindings live on `MainWindow`: `Ctrl+K` focuses search, `Ctrl+N` the add-task box. Do **not** bind bare punctuation gestures — `OemQuestion` used to hold search focus and silently swallowed `#` app-wide on a German layout.
- `FocusClearing` also clears focus from a TextBox on Escape, mirroring its click-outside behavior — but the KeyDown handler is scoped to `MainWindow` specifically (`AddClassHandler<MainWindow>`, not `<TopLevel>`). Modal windows (`AboutModalView` etc.) each bind their own `Window.KeyBindings` Escape → close; since modals are separate `Window` instances, this handler never runs there, so Escape still closes them unchanged. Mission Control's ConPTY tiles (`InteractiveTerminalView`) live in `MissionControlWindow`, also unaffected — Escape always reaches the PTY there.
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner: used for a starting ConPTY pane (`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while `TaskRowViewModel.IsRefining`.
- `ConPtyPaneViewModel` resolves its own launch spec (ctor takes a descriptor **factory**; the host wires handlers and then calls `Start()`), so the Mission Control tile appears immediately with its spinner while the worker is still preparing the worktree. A failed launch keeps the tile with its inline error banner instead of never appearing.
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de).
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered with a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style). A pure stroke path in a `PathIcon` is **invisible**.
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
- **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout.
- **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**.
- **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md).
- Context menus exist on both list and task rows; right-click selects before opening the menu.
- "Run Now" CanExecute re-evaluates when worker connection state changes.
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner (starting ConPTY pane, refining task row).
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) — used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" overlay; `DescriptionStepsCard` shows the attachments list, an "Add file…" picker, and an explicit `DropStatus` line. Keys use the `details.attachments.*` locale namespace (en + de).
@@ -1,30 +0,0 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClaudeDo.Ui.Converters;
public sealed class CheckboxBorderConverter : IValueConverter
{
public static readonly CheckboxBorderConverter Instance = new();
private static readonly ISolidColorBrush Gray = new SolidColorBrush(Color.Parse("#475569"));
private static readonly ISolidColorBrush Orange = new SolidColorBrush(Color.Parse("#e67e22"));
private static readonly ISolidColorBrush Green = new SolidColorBrush(Color.Parse("#3d9474"));
private static readonly ISolidColorBrush Red = new SolidColorBrush(Color.Parse("#ef4444"));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value?.ToString()?.ToLowerInvariant() switch
{
"running" => Orange,
"done" => Green,
"failed" => Red,
_ => Gray, // manual, queued
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
@@ -1,23 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace ClaudeDo.Ui.Converters;
public sealed class DateOnlyToDateTimeConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is DateOnly d)
return d.ToDateTime(TimeOnly.MinValue);
return null;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is DateTime dt)
return DateOnly.FromDateTime(dt);
if (value is DateTimeOffset dto)
return DateOnly.FromDateTime(dto.LocalDateTime);
return DateOnly.FromDateTime(DateTime.Today);
}
}
@@ -0,0 +1,24 @@
using System.Globalization;
using Avalonia.Data;
using Avalonia.Data.Converters;
namespace ClaudeDo.Ui.Converters;
/// <summary>
/// For <c>NumericUpDown.Value</c> bound to a non-nullable numeric property. The control's Value is
/// <c>decimal?</c> and goes null the moment the text box is empty — which is exactly what happens
/// while the user clears a value to type a new one. Writing that null into an <c>int</c>/<c>decimal</c>
/// target throws <see cref="InvalidCastException"/>, so swallow it and leave the source untouched
/// until a real number arrives.
/// </summary>
public class KeepLastNumberConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is null ? null : System.Convert.ToDecimal(value, culture);
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is null) return BindingOperations.DoNothing;
return System.Convert.ChangeType(value, Nullable.GetUnderlyingType(targetType) ?? targetType, culture);
}
}
@@ -1,44 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClaudeDo.Ui.Converters;
public class StatusColorConverter : IValueConverter
{
public static StatusColorConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var status = value?.ToString()?.ToLowerInvariant();
return status switch
{
"queued" => Brushes.DodgerBlue,
"running" => Brushes.Orange,
"waitingforreview" => Brushes.MediumPurple,
"waiting_for_review" => Brushes.MediumPurple,
"waitingforchildren" => Brushes.DarkOrange,
"done" => Brushes.Green,
"failed" => Brushes.Red,
"manual" => Brushes.Gray,
_ => Brushes.Transparent,
};
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class ConnectionColorConverter : IValueConverter
{
public static ConnectionColorConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var text = value?.ToString();
return text == "Online" ? Brushes.Green : Brushes.Red;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
-57
View File
@@ -8,7 +8,6 @@
<Button Classes="icon-btn"> — 24×24 icon button
<Button Classes="btn primary"> — rounded-rect button
<TextBlock Classes="eyebrow"> — uppercase mono label
<Border Classes="agent-strip running"> — agent status strip
<Border Classes="terminal"> — terminal/log window
-->
<Styles xmlns="https://github.com/avaloniaui"
@@ -489,45 +488,6 @@
<Setter Property="Stroke" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- ============================================================ -->
<!-- AGENT STRIP -->
<!-- ============================================================ -->
<Style Selector="Border.agent-strip">
<Setter Property="Padding" Value="12,10" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<Style Selector="Border.agent-strip.running">
<Setter Property="Background" Value="{StaticResource RunningTintBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource RunningTintBorderBrush}" />
</Style>
<Style Selector="Border.agent-strip.review">
<Setter Property="Background" Value="{StaticResource ReviewTintBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource ReviewTintBorderBrush}" />
</Style>
<Style Selector="Border.agent-strip.children">
<Setter Property="Background" Value="#332A1A" />
<Setter Property="BorderBrush" Value="#4D3A1A" />
</Style>
<Style Selector="Border.agent-strip.error">
<Setter Property="Background" Value="{StaticResource ErrorTintBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource ErrorTintBorderBrush}" />
</Style>
<!-- queued → Sage tint -->
<Style Selector="Border.agent-strip.queued">
<Setter Property="Background" Value="{StaticResource QueuedTintBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource QueuedTintBorderBrush}" />
</Style>
<!-- idle → neutral (same as base, explicit for clarity) -->
<Style Selector="Border.agent-strip.idle">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<!-- ============================================================ -->
<!-- TERMINAL / LOG -->
<!-- ============================================================ -->
@@ -959,23 +919,6 @@
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- ============================================================ -->
<!-- DIFF METER -->
<!-- ============================================================ -->
<!-- Outer track (full width, line-bright bg) -->
<Style Selector="Border.diff-meter-track">
<Setter Property="Height" Value="4" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Background" Value="{StaticResource LineBrightBrush}" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<!-- Filled portion (moss; width set via ScaleTransform or Width binding in view) -->
<Style Selector="Rectangle.diff-meter-fill">
<Setter Property="Height" Value="4" />
<Setter Property="Fill" Value="{StaticResource MossBrightBrush}" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<!-- ============================================================ -->
<!-- SUBTASK ROW -->
<!-- ============================================================ -->
@@ -25,13 +25,20 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
event Action<string, string>? TaskQuestionResolvedEvent;
/// <summary>A running list-handler session called handoff_list_handler at the end of Phase 2:
/// (handlerTaskId, survivingTaskIds). The UI opens a second ConPTY tile for the same task.</summary>
event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent;
event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent;
event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
/// is true when an MCP session (not the UI) started the unit merge — the resolver must not
/// auto-open in that case.</summary>
event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
event Action<string>? PlanningMergeAbortedEvent;
event Action<string>? PlanningCompletedEvent;
@@ -50,11 +57,20 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId);
Task ResetTaskAsync(string taskId);
Task CancelTaskAsync(string taskId);
/// <summary>Deletes a task via the worker (mirrors the MCP delete_task tool), so a deleted
/// child correctly advances a WaitingForChildren parent. Returns (false, message) instead of
/// throwing when the task has children or is running, preserving the FK-error UX.</summary>
Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId);
Task<List<AgentInfo>> GetAgentsAsync();
Task RefreshAgentsAsync();
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
/// <summary>Repo-import folders remembered for the "Add repos as lists" dialog. Throws on
/// a failed hub call — the caller surfaces the failure rather than showing an empty list.</summary>
Task<List<string>> GetRepoImportFoldersAsync();
/// <summary>Persists the repo-import folder list. Throws on a failed hub call.</summary>
Task SetRepoImportFoldersAsync(List<string> folders);
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
Task<List<string>> InstallSessionSkillAsync(string url);
Task UpdateSessionSkillAsync(string sourceUrl);
@@ -90,6 +106,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default);
/// <summary>Launch spec for the fresh ConPTY session a merge-helper run hands off to once Phase 2
/// is done -- reuses the SAME handler task id (no new task created).</summary>
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
@@ -104,6 +124,9 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
Task ContinuePlanningMergeAsync(string planningTaskId);
Task AbortPlanningMergeAsync(string planningTaskId);
/// <summary>Unit merges currently paused on a conflict that an MCP session started. Called
/// on (re)connect to recover the "don't auto-open" banner state after a UI restart.</summary>
Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync();
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
@@ -122,6 +145,9 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<string> GetLastPrepLogAsync();
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
/// <summary>Git SHA the running worker was built from (null when offline or the build isn't stamped).</summary>
Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync();
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
Task DeletePrimeScheduleAsync(Guid id);
@@ -143,6 +169,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>Raised whenever the worker's usage poller ticks (success or failure).</summary>
event Action<UsageSnapshotDto>? UsageUpdatedEvent;
Task<UsageSnapshotDto?> GetUsageSnapshotAsync();
/// <summary>Forces an out-of-band usage poll on the worker and returns the fresh snapshot.</summary>
Task<UsageSnapshotDto?> RefreshUsageAsync();
Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to);
Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to);
}
+41 -4
View File
@@ -19,6 +19,28 @@ namespace ClaudeDo.Ui.Services;
/// </summary>
public sealed class PtyTerminalSession : IDisposable
{
// Guards the set-env + LaunchProcess critical section below: two sessions starting
// back-to-back (e.g. planning sessions for two different tasks) could otherwise interleave
// their SetEnvironmentVariable calls before either LaunchProcess() forks, so one process
// inherits the other's env (e.g. CLAUDEDO_PLANNING_TOKEN, breaking that session's own MCP
// auth). Process-wide env leakage AFTER a launch has forked remains a documented limitation
// — Porta.Pty has no per-launch env seam, so the vars stay set on the whole UI process.
// The wait is bounded (see WaitForLaunchGateAsync) — a hung launch (slow disk, AV scanning
// claude.exe, a Porta.Pty/ConPTY hiccup) must not freeze every other pane open behind it.
private static readonly SemaphoreSlim s_launchGate = new(1, 1);
private static readonly TimeSpan s_launchGateTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Waits on <paramref name="gate"/> for at most <paramref name="timeout"/>, throwing
/// <see cref="TimeoutException"/> instead of blocking forever. Never acquires the gate on
/// timeout, so callers must not release it in that case.
/// </summary>
internal static async Task WaitForLaunchGateAsync(SemaphoreSlim gate, TimeSpan timeout, CancellationToken ct)
{
if (!await gate.WaitAsync(timeout, ct))
throw new TimeoutException("Another terminal launch is still starting up. Please retry in a moment.");
}
private TerminalControl? _control;
private bool _disposed;
@@ -38,14 +60,29 @@ public sealed class PtyTerminalSession : IDisposable
_control = control;
control.ProcessExited += OnControlProcessExited;
foreach (var (key, value) in descriptor.Env)
Environment.SetEnvironmentVariable(key, value);
control.Process = descriptor.Exe;
control.Args = new List<string>(descriptor.Args);
control.StartingDirectory = descriptor.Cwd;
await control.LaunchProcess();
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
try
{
foreach (var (key, value) in descriptor.Env)
Environment.SetEnvironmentVariable(key, value);
await control.LaunchProcess();
}
finally
{
s_launchGate.Release();
}
// Permanent reparent mode: TerminalView.OnDetachedFromLogicalTree kills the child
// process unless BeginReparent() suppressed it, and Mission Control detaches pane
// views routinely (overview-grid rebuilds on pane add/remove, focus-mode tab
// switches). ClaudeDo owns teardown explicitly instead — ConPtyPaneViewModel.Dispose
// calls Kill() when a pane closes — so EndReparent is deliberately never called.
control.BeginReparent();
IsRunning = true;
}
+58 -9
View File
@@ -4,23 +4,72 @@ public sealed record RepoCandidate(string Name, string FullPath);
public static class RepoScanner
{
private const int MaxDepth = 5;
private static readonly HashSet<string> SkipDirNames = new(StringComparer.OrdinalIgnoreCase)
{
"node_modules", "bin", "obj", ".git", ".vs", "packages",
};
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
{
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
return Array.Empty<RepoCandidate>();
var result = new List<RepoCandidate>();
IEnumerable<string> subdirs;
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
try
{
var info = new DirectoryInfo(parentFolder);
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint))
return Array.Empty<RepoCandidate>();
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{ return Array.Empty<RepoCandidate>(); }
foreach (var dir in subdirs)
{
var gitPath = Path.Combine(dir, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
}
var result = new List<RepoCandidate>();
ScanDirectory(parentFolder, depth: 0, result);
return result;
}
private static void ScanDirectory(string dir, int depth, List<RepoCandidate> result)
{
if (IsRepo(dir))
{
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
return;
}
if (depth >= MaxDepth)
return;
IEnumerable<string> subdirs;
try { subdirs = Directory.EnumerateDirectories(dir); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{ return; }
foreach (var subdir in subdirs)
{
var name = Path.GetFileName(subdir);
if (SkipDirNames.Contains(name))
continue;
try
{
var info = new DirectoryInfo(subdir);
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint))
continue;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{ continue; }
try { ScanDirectory(subdir, depth + 1, result); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{ }
}
}
private static bool IsRepo(string dir)
{
var gitPath = Path.Combine(dir, ".git");
return Directory.Exists(gitPath) || File.Exists(gitPath);
}
}
+55 -5
View File
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? TaskUpdatedEvent;
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent;
@@ -65,7 +66,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent;
@@ -150,6 +151,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
});
_hub.On<string, IReadOnlyList<string>>("HandoffRequested", (taskId, survivingTaskIds) =>
{
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds));
});
_hub.On<string>("WorktreeUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
@@ -176,9 +182,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId));
});
_hub.On<string, string, IReadOnlyList<string>>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles) =>
_hub.On<string, string, IReadOnlyList<string>, bool>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles, externallyDriven) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles));
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles, externallyDriven));
});
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
@@ -321,6 +327,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("CancelTask", taskId);
}
public async Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId)
{
try
{
await _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None);
return (true, null);
}
catch (HubException ex)
{
return (false, ex.Message);
}
catch (InvalidOperationException)
{
// Hub connection is not active (worker offline / not yet connected).
return (false, "Worker is offline. Reconnect and try again.");
}
}
public async Task WakeQueueAsync()
{
await _hub.InvokeAsync("WakeQueue");
@@ -417,6 +441,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync()
=> TryInvokeAsync<WorkerBuildInfoDto>("GetWorkerBuildInfo");
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
@@ -438,6 +465,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
}
public async Task<List<string>> GetRepoImportFoldersAsync()
=> await _hub.InvokeAsync<List<string>>("GetRepoImportFolders");
public Task SetRepoImportFoldersAsync(List<string> folders)
=> _hub.InvokeAsync("SetRepoImportFolders", folders);
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
@@ -531,6 +564,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
=> await _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct);
public async Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, ct);
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
@@ -562,6 +599,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
}
public async Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
=> await TryInvokeAsync<List<PlanningMergeConflictStateDto>>("GetActiveExternalPlanningMergeConflicts")
?? [];
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
{
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
@@ -582,6 +623,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
=> TryInvokeAsync<UsageSnapshotDto>("GetUsageSnapshot");
public Task<UsageSnapshotDto?> RefreshUsageAsync()
=> TryInvokeAsync<UsageSnapshotDto>("RefreshUsage");
public async Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
=> await TryInvokeAsync<List<ModelUsageRowDto>>("GetModelUsage", from, to) ?? [];
@@ -625,7 +669,8 @@ public sealed record AppSettingsDto(
List<string>? SessionSkills = null,
List<ModelPresetDto>? ModelPresets = null,
int UsageGateFiveHourPct = 80,
int UsageGateSevenDayPct = 90);
int UsageGateSevenDayPct = 90,
int MaxTurnsCeiling = 80);
// Per-model run defaults (effort + turn budget) edited in Settings → General.
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -672,7 +717,9 @@ public sealed record LaunchSpec(
IReadOnlyDictionary<string, string> Env);
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
public sealed record PlanningMergeConflictStateDto(string PlanningTaskId, string SubtaskId);
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
public sealed record WorkerBuildInfoDto(string? BuildSha);
public sealed record OnlineInboxStateDto(
bool Enabled,
@@ -714,7 +761,10 @@ public sealed record UsageSnapshotDto(
string? GateReason,
DateTime? FetchedAtUtc,
bool IsStale,
string? LastError);
string? LastError,
int ConfiguredSlots,
int EffectiveSlots,
string? ThrottleBucket);
public sealed record ModelUsageRowDto(
DateOnly Date,
@@ -43,6 +43,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
[ObservableProperty] private string _modelInheritedHint = "";
[ObservableProperty] private string _turnsBadge = "";
[ObservableProperty] private string _turnsInheritedHint = "";
[ObservableProperty] private string _turnsCeilingHint = "";
[ObservableProperty] private string _agentBadge = "";
[ObservableProperty] private string _effectiveSystemPromptHint = "";
@@ -50,6 +51,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
// The global max-turns default is per-model (Settings -> General), so it moves with whichever
// model actually ends up in effect here.
private IReadOnlyList<ModelPreset> _presets = ModelPresets.Defaults;
private int _maxTurnsCeiling = 80;
private string EffectiveModel => Model ?? _listModel ?? _globalModel;
private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns;
private string? _listModel; // Task scope only
@@ -145,6 +147,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
: InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString());
TurnsInheritedHint = value;
TurnsBadge = BadgeFor(source, MaxTurns is not null);
TurnsCeilingHint = MaxTurns is decimal t && (int)t > _maxTurnsCeiling
? Loc.T("settings.turnsCeilingHint", _maxTurnsCeiling)
: "";
}
private void RecomputeAgentBadge()
@@ -301,6 +306,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
_presets = app?.ModelPresets is { Count: > 0 } rows
? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList()
: ModelPresets.Defaults;
_maxTurnsCeiling = app?.MaxTurnsCeiling ?? 80;
}
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath)
@@ -13,9 +13,18 @@ namespace ClaudeDo.Ui.ViewModels;
/// </summary>
public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDisposable
{
// A process that dies within this window of becoming "running" is treated as a startup
// failure (see OnSessionProcessExited) rather than a normal session end, even though the
// spawn itself succeeded — e.g. `claude --session-id <guid>` exiting immediately on an
// auth/network hiccup. Long enough to clear normal CLI startup, short enough not to
// mistake a real crash-after-use for a start failure.
private static readonly TimeSpan StartupGraceWindow = TimeSpan.FromSeconds(5);
private readonly PtyTerminalSession _session = new();
private readonly Func<DateTime> _utcNow;
private TerminalControl? _control;
private TerminalLaunchDescriptor? _pendingDescriptor;
private DateTime? _startedAtUtc;
[ObservableProperty] private bool _isRunning;
[ObservableProperty] private bool _hasExited;
@@ -27,12 +36,23 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
/// can show a spinner instead of an empty black pane.</summary>
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
partial void OnIsRunningChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
partial void OnIsRunningChanged(bool value)
{
OnPropertyChanged(nameof(IsStarting));
if (value) _startedAtUtc = _utcNow();
}
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
public InteractiveTerminalViewModel()
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
{
}
/// <summary>Test-only seam for controlling "time since launch" without real delays.</summary>
internal InteractiveTerminalViewModel(Func<DateTime> utcNow)
{
_utcNow = utcNow;
_session.ProcessExited += OnSessionProcessExited;
}
@@ -76,11 +96,18 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
}
}
private void OnSessionProcessExited(object? sender, int exitCode)
/// <summary>Internal (not private) so tests can drive it directly without a real ConPTY spawn.</summary>
internal void OnSessionProcessExited(object? sender, int exitCode)
{
IsRunning = false;
HasExited = true;
ExitCode = exitCode;
// Died at startup: route through the same banner as a launch-time failure instead of
// leaving a dead terminal with no Retry affordance (ConPtyPaneViewModel.CanRetry
// requires StartError).
if (exitCode != 0 && _startedAtUtc is { } startedAt && _utcNow() - startedAt < StartupGraceWindow)
StartError = $"Session exited immediately (code {exitCode}).";
}
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
@@ -35,6 +35,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
private readonly Action<string> _workerWorktreeUpdatedHandler;
private readonly Action<string> _workerTaskUpdatedHandler;
private readonly Action<string, string> _workerPlanningMergeStartedHandler;
private readonly Action<string> _workerPlanningMergeAbortedHandler;
private readonly Action<string> _workerPlanningCompletedHandler;
[ObservableProperty] private bool _isNotesMode;
[ObservableProperty] private bool _isPrepMode;
@@ -219,6 +222,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _branchLine;
[ObservableProperty] private int _turns;
[ObservableProperty] private int _tokens;
[ObservableProperty] private string? _tokensBreakdown;
[ObservableProperty] private int _diffAdditions;
[ObservableProperty] private int _diffDeletions;
[ObservableProperty] private int _commitsOnBranch;
@@ -350,6 +354,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
ResetAndRetryCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
SendRoadblockReplyCommand.NotifyCanExecuteChanged();
CancelReviewCommand.NotifyCanExecuteChanged();
DeleteTaskCommand.NotifyCanExecuteChanged();
}
};
_worker.PropertyChanged += _workerPropertyChangedHandler;
@@ -365,6 +371,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (Task?.Id != taskId) return;
_ = RefreshWorktreeAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId);
// The run only gets a session id once it has started, so a task that was already
// selected while it ran still holds the null from selection time. Without this the
// roadblock reply box and Continue stay dead until the task is re-selected.
_ = RefreshLatestRunSessionIdAsync(taskId);
};
_worker.TaskFinishedEvent += _workerTaskFinishedHandler;
@@ -387,6 +397,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
};
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
_workerPlanningMergeStartedHandler = (planningTaskId, _) =>
{
if (Task?.Id == planningTaskId) IsMergeDraining = true;
};
_worker.PlanningMergeStartedEvent += _workerPlanningMergeStartedHandler;
_workerPlanningMergeAbortedHandler = planningTaskId =>
{
if (Task?.Id == planningTaskId) IsMergeDraining = false;
};
_worker.PlanningMergeAbortedEvent += _workerPlanningMergeAbortedHandler;
_workerPlanningCompletedHandler = planningTaskId =>
{
if (Task?.Id == planningTaskId) IsMergeDraining = false;
};
_worker.PlanningCompletedEvent += _workerPlanningCompletedHandler;
ChildOutcomes.CollectionChanged += (_, _) =>
{
Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
@@ -396,6 +424,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public void Dispose()
{
if (_subscribedTask is not null)
_subscribedTask.PropertyChanged -= OnBoundTaskPropertyChanged;
Monitor.PropertyChanged -= OnMonitorPropertyChanged;
Monitor.Dispose();
Loc.LanguageChanged -= _langChangedHandler;
@@ -404,6 +434,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
_worker.PlanningMergeStartedEvent -= _workerPlanningMergeStartedHandler;
_worker.PlanningMergeAbortedEvent -= _workerPlanningMergeAbortedHandler;
_worker.PlanningCompletedEvent -= _workerPlanningCompletedHandler;
AgentSettings.Dispose();
Prep.Dispose();
}
@@ -433,6 +466,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
DequeueCommand.NotifyCanExecuteChanged();
ResetAndRetryCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
CancelReviewCommand.NotifyCanExecuteChanged();
// A state change means a new run/review cycle: the diff must be
// re-inspected before merge can be approved again.
ReviewDiffViewed = false;
@@ -542,6 +576,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
OnPropertyChanged(nameof(TaskIdBadge));
Monitor.Reset();
RoadblockReplyDraft = string.Empty;
IsMergeDraining = false;
Subtasks.Clear();
ChildOutcomes.Clear();
Attachments.Clear();
@@ -619,7 +654,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Restore turn/token counts from the last run so a reloaded terminal task
// shows its real turns instead of "0/max".
Turns = latestRun?.TurnCount ?? 0;
Tokens = (latestRun?.TokensIn ?? 0) + (latestRun?.TokensOut ?? 0);
// Raw total = what actually counts against the 5h/7d Claude usage limit: cached
// context resend (cache-read + cache-write) dwarfs fresh input/output on a
// resumed session, so it must be included, not just the uncached input/output.
var tokensIn = latestRun?.TokensIn ?? 0;
var tokensOut = latestRun?.TokensOut ?? 0;
var cacheRead = latestRun?.CacheReadTokens ?? 0;
var cacheWrite = latestRun?.CacheWriteTokens ?? 0;
Tokens = tokensIn + tokensOut + cacheRead + cacheWrite;
TokensBreakdown = $"in {tokensIn} · out {tokensOut} · cache-read {cacheRead} · cache-write {cacheWrite}";
Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
Monitor.SetTaskId(row.Id);
@@ -792,6 +835,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Refreshes the bound row itself (status, planning phase, worktree/branch mirrors, etc.) from
// the DB. Kept independent of TasksIslandViewModel's own handler: the row instance backing
// Task may have been replaced by a full list reload, so this must not assume it stayed live.
private async System.Threading.Tasks.Task RefreshLatestRunSessionIdAsync(string taskId)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
if (Task?.Id != taskId) return;
LatestRunSessionId = latestRun?.SessionId;
}
catch { /* best-effort */ }
}
private async System.Threading.Tasks.Task RefreshBoundTaskAsync(string taskId)
{
try
@@ -855,14 +910,33 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
// Tracks whichever row we last subscribed to, so a later Task switch can unsubscribe the old
// one cleanly even though the generated OnTaskChanged only hands us the new value.
private TaskRowViewModel? _subscribedTask;
partial void OnTaskChanged(TaskRowViewModel? value)
{
if (_subscribedTask is not null)
_subscribedTask.PropertyChanged -= OnBoundTaskPropertyChanged;
_subscribedTask = value;
if (value is not null)
value.PropertyChanged += OnBoundTaskPropertyChanged;
ReviewDiffViewed = false;
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
}
// The bound row's HasInteractiveSession can flip from outside (Mission Control opening/closing
// a ConPTY pane) without Task itself changing, so ResetAndRetryCommand needs its own listener
// to stay in sync with the gate in CanResetAndRetry.
private void OnBoundTaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TaskRowViewModel.HasInteractiveSession))
ResetAndRetryCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
@@ -911,7 +985,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
await repo.UpdateAsync(entity);
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanDeleteTask))]
private async System.Threading.Tasks.Task DeleteTaskAsync()
{
if (Task == null) return;
@@ -921,18 +995,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
if (!ok) return;
}
// Routed through the worker (mirrors the MCP delete_task tool) so a deleted child
// correctly advances a WaitingForChildren parent — a direct-repo delete from here used
// to bypass TaskStateService.TryAdvanceParentAsync and could wedge the parent forever.
bool deleted;
string? error;
try
{
await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
await repo.DeleteAsync(row.Id);
(deleted, error) = await _worker.DeleteTaskAsync(row.Id);
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when (
ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
catch (Exception ex)
{
// Belt and braces: the connection can drop between the CanExecute check and
// this call, so a stray throw here must surface, not vanish silently.
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
return;
}
if (!deleted)
{
if (ShowErrorAsync != null)
await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
await ShowErrorAsync(error ?? "Delete failed.");
return;
}
if (DeleteFromList != null)
@@ -940,6 +1023,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
CloseDetail?.Invoke();
}
private bool CanDeleteTask() => Task != null && _worker.IsConnected;
[RelayCommand]
private async System.Threading.Tasks.Task CommitSubtaskEditAsync(SubtaskRowViewModel? row)
{
@@ -1085,8 +1170,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { /* offline */ }
}
// Reset & Retry discards the branch/uncommitted work and queues an autonomous run into the
// same worktree — must stay off while the user is hand-editing it in an interactive ConPTY
// pane, same reasoning as TaskRowViewModel.CanSendToQueue.
private bool CanResetAndRetry() =>
Task != null && _worker.IsConnected && ShowResetAndRetry;
Task != null && _worker.IsConnected && ShowResetAndRetry && !Task.HasInteractiveSession;
// Set once the user opens the diff/combined-diff for the current review. Reset on
// task switch and on every state change (a new run means a new diff to read), so
@@ -1179,14 +1267,28 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { /* stale review action; broadcast reconciles */ }
}
[RelayCommand]
// True while a unit merge is actively draining this task's subtasks onto the target
// branch — set from PlanningMergeStarted, cleared on PlanningMergeAborted/Completed.
// Mirrors the worker-side guard in TaskStateService.CancelAsync (the real correctness
// fix); this just keeps the button from inviting a click the worker will reject anyway.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(CancelReviewCommand))]
private bool _isMergeDraining;
[RelayCommand(CanExecute = nameof(CanCancelReview))]
private async System.Threading.Tasks.Task CancelReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
try { await _worker.CancelReviewAsync(Task.Id); }
catch { /* stale review action; broadcast reconciles */ }
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
}
}
private bool CanCancelReview() =>
Task != null && _worker.IsConnected && !IsMergeDraining;
private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
{
if (Task is null) return;
@@ -30,6 +30,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
public event EventHandler? FocusSearchRequested;
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
// mirrors TasksIslandViewModel.ErrorReported — surfaces modal-owned failures in the footer strip.
public event Action<string>? ErrorReported;
public IDialogService? Dialogs { get; set; }
[RelayCommand]
@@ -58,6 +61,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
if (Dialogs is null || _services is null) return;
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
await vm.LoadAsync();
await Dialogs.ShowRepoImportAsync(vm);
await LoadAsync();
@@ -121,6 +125,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
ForegroundHelper.AllowAny();
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
@@ -99,11 +99,15 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
// it must be finalized first.
// it must be finalized first. The worker never sees a UI-hosted ConPTY session (it never
// touches task status), so this gate has to live here: queueing a task the user is actively
// hand-editing in an interactive pane would spawn an autonomous run racing it in the same
// worktree.
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized)
&& PlanningPhase != PlanningPhase.Active
&& !IsManual;
&& !IsManual
&& !HasInteractiveSession;
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
// Drives the routing inside SendToQueue, not a separate menu entry.
public bool CanQueuePlan => !IsChild && HasPlanningChildren
@@ -242,6 +246,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(InteractiveChipTooltip));
OnPropertyChanged(nameof(CanSendToQueue));
}
partial void OnHasQueuedSubtasksChanged(bool value)
@@ -73,12 +73,34 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private bool _showNotesRow;
[ObservableProperty] private bool _isMyDayList;
[ObservableProperty] private bool _isLetClaudeVisible;
[ObservableProperty] private bool _isQuickClaudeVisible;
public event EventHandler? LetClaudeHandleRequested;
[RelayCommand]
private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty);
// Opens a task-less ConPTY session directly in the current list's working dir. The shell owns
// Mission Control, so this just raises an event for it to act on (mirrors OpenConPtySessionRequested).
public event Action<string>? OpenQuickClaudeSessionRequested;
[RelayCommand]
private void OpenQuickClaudeSession()
{
var dir = _currentList?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir))
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeNoWorkingDir"));
return;
}
if (!System.IO.Directory.Exists(dir))
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeDirMissing", dir));
return;
}
OpenQuickClaudeSessionRequested?.Invoke(dir);
}
internal Task? LoadTask { get; private set; }
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
@@ -221,7 +243,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (e.PropertyName == nameof(ListNavItemViewModel.Name))
HeaderTitle = vm.Name;
else if (e.PropertyName == nameof(ListNavItemViewModel.WorkingDir))
{
IsLetClaudeVisible = vm.Kind == ListKind.User && !string.IsNullOrWhiteSpace(vm.WorkingDir);
IsQuickClaudeVisible = IsLetClaudeVisible;
}
}
public void LoadForList(ListNavItemViewModel? list)
@@ -259,13 +284,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
ShowOpenLabel = false;
ShowNotesRow = false;
if (listChanged) SelectedTask = null;
if (list is null) { IsLetClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
ShowNotesRow = list.Id == "smart:my-day";
IsMyDayList = list.Id == "smart:my-day";
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
IsQuickClaudeVisible = IsLetClaudeVisible;
LoadTask = LoadForListAsync(list, ct, reusable);
}
@@ -807,26 +833,31 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
if (row is null || row.IsRunning) return;
if (row is null || row.IsRunning || row.HasInteractiveSession || _worker is null) return;
// A finalized planning parent queues its plan (children sequentially), not itself.
if (row.CanQueuePlan)
{
// The hub's QueuePlanningSubtasksAsync queues every Idle child unconditionally — it has
// no notion of a UI-hosted ConPTY session. Block the whole plan if any child has one open,
// otherwise that child's worktree would get an autonomous run racing the user's own edits.
var interactiveChildren = Items
.Where(r => r.ParentTaskId == row.Id && r.HasInteractiveSession)
.Select(r => r.Title)
.ToList();
if (interactiveChildren.Count > 0)
{
ErrorReported?.Invoke(Loc.T(
"vm.tasksIsland.queuePlanBlockedInteractive", string.Join(", ", interactiveChildren)));
return;
}
await QueuePlanningSubtasksAsync(row);
return;
}
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
entity.Status = TaskStatus.Queued;
await db.SaveChangesAsync();
row.Status = TaskStatus.Queued;
if (_worker is not null)
{
try { await _worker.WakeQueueAsync(); } catch { }
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
// Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
// so the manual/draft-child guards apply here too; the row refreshes from the resulting
// TaskUpdated broadcast.
try { await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued); }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
}
[RelayCommand]
@@ -912,7 +943,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
if (row is null || !row.IsWaitingForReview || _worker is null) return;
try { await _worker.CancelReviewAsync(row.Id); }
catch { /* offline; broadcast reconciles on return */ }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.cancelReviewFailed", ex.Message)); }
}
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
@@ -34,6 +35,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private readonly UpdateCheckService _updateCheck = null!;
private readonly InstallerLocator _installerLocator = null!;
private readonly WorkerLocator _workerLocator = null!;
private readonly GitService? _git;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
@@ -102,6 +104,18 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _updateBannerLatestVersion;
private bool _bannerDismissedThisSession;
// Persistent (not auto-clearing) banner: the running worker predates the selected list's
// merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo
// only — see RefreshStaleWorkerCheckAsync.
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
// Kept as a dictionary so a later conflict on the same planning task updates in place instead
// of piling up duplicate entries.
private readonly Dictionary<string, string> _externalMergeConflicts = new();
[ObservableProperty] private bool _isExternalMergeBannerVisible;
[ObservableProperty]
private double _windowWidth = 1280;
@@ -178,13 +192,59 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_primeStatusTimer.Start();
}
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
public void OnPlanningMergeConflict(
string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles, bool externallyDriven)
{
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
if (externallyDriven)
{
// An MCP session (review_task/continue_merge) is driving this merge — it owns
// resolution. Auto-opening the resolver here raced with the session's own writes
// (two parties editing the same shared checkout at once); show a banner instead and
// leave the resolver reachable only via a deliberate click.
_externalMergeConflicts[planningTaskId] = subtaskId;
IsExternalMergeBannerVisible = true;
return;
}
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
public void OnPlanningMergeAborted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
private void ClearExternalMergeConflict(string planningTaskId)
{
_externalMergeConflicts.Remove(planningTaskId);
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
}
/// <summary>Re-syncs the external-merge banner from the worker on (re)connect — the
/// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is
/// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active,
/// so a session that died mid-merge without cleaning up doesn't leave a stale banner up.</summary>
private async Task RefreshExternalMergeConflictsAsync()
{
if (Worker is null) return;
IReadOnlyList<PlanningMergeConflictStateDto> active;
try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); }
catch { return; }
_externalMergeConflicts.Clear();
foreach (var c in active)
_externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId;
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
}
[RelayCommand]
private Task OpenExternalMergeConflictAsync()
{
if (_externalMergeConflicts.Count == 0) return Task.CompletedTask;
var (planningTaskId, subtaskId) = _externalMergeConflicts.First();
return OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
{
if (ConflictResolverFactory is null || Dialogs is null) return;
@@ -212,7 +272,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory,
MissionControlViewModel missionControl,
UsagePillViewModel usagePill)
UsagePillViewModel usagePill,
GitService? git = null)
{
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
MissionControl = missionControl;
@@ -232,16 +293,24 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_usageMonitorVmFactory = usageMonitorVmFactory;
_mergeVmFactory = mergeVmFactory;
_repoImportVmFactory = repoImportVmFactory;
_git = git;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.ErrorReported += FlashFooterError;
Lists.ErrorReported += FlashFooterError;
Tasks.OpenConPtySessionRequested += taskId =>
{
OpenMissionControl();
_ = MissionControl.OpenConPtySessionAsync(taskId);
};
Tasks.OpenQuickClaudeSessionRequested += dir =>
{
OpenMissionControl();
_ = MissionControl.OpenAdHocConPtySessionAsync(dir);
};
Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
{
OpenMissionControl();
@@ -280,7 +349,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
}
};
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
Worker.PrimeFired += OnPrimeFired;
_clearTimer.Elapsed += (_, _) =>
{
@@ -319,6 +392,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_connectTimer.Dispose();
_primeStatusTimer.Stop();
_primeStatusTimer.Dispose();
_staleWorkerCts?.Cancel();
_staleWorkerCts?.Dispose();
}
private void RefreshBannerFromStatus()
@@ -338,6 +413,57 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
}
}
private CancellationTokenSource? _staleWorkerCts;
// Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call +
// up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on
// every selection change / reconnect rather than caching.
private async Task RefreshStaleWorkerCheckAsync()
{
_staleWorkerCts?.Cancel();
var cts = new CancellationTokenSource();
_staleWorkerCts = cts;
var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token);
if (cts.IsCancellationRequested) return;
IsStaleWorkerBannerVisible = stale;
}
private async Task<bool> ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct)
{
if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false;
try
{
var buildInfo = await Worker.GetWorkerBuildInfoAsync();
var buildSha = buildInfo?.BuildSha;
if (string.IsNullOrWhiteSpace(buildSha)) return false;
if (!await _git.IsGitRepoAsync(workingDir, ct)) return false;
var head = await _git.RevParseHeadAsync(workingDir, ct);
var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase)
? (bool?)false // equal — never "stale" on a match, and no need to ask git
: await _git.IsAncestorAsync(workingDir, buildSha, head, ct);
return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor);
}
catch
{
// Worker offline, dir no longer a repo, etc. — unknown, so stay quiet.
return false;
}
}
// Pure decision extracted for testability. isAncestor is the tri-state result of
// `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false =
// equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown
// (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale").
internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor)
{
if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false;
if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false;
return isAncestor == true;
}
[RelayCommand]
private void OpenMissionControl()
{
@@ -347,7 +473,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private void SyncInteractiveSessionChips()
{
if (MissionControl is null) return;
if (MissionControl is null || Tasks is null) return;
Tasks.SyncInteractiveSessions(
MissionControl.ConPtySessions
.Select(s => s.TaskId)
@@ -20,7 +20,11 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
[ObservableProperty] private string _displayTitle;
public InteractiveTerminalViewModel Terminal { get; } = new();
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
/// <summary>Set by the host (Mission Control) while a submit-for-review round trip is in
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
[ObservableProperty] private bool _isSubmitPending;
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
public event Action<string>? ErrorReported;
@@ -76,12 +80,21 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
{
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
ErrorReported?.Invoke(error);
if (e.PropertyName is nameof(InteractiveTerminalViewModel.IsStarting)
or nameof(InteractiveTerminalViewModel.StartError)
or nameof(InteractiveTerminalViewModel.HasExited))
{
SubmitForReviewCommand.NotifyCanExecuteChanged();
RetryCommand.NotifyCanExecuteChanged();
}
}
[RelayCommand]
private void Close() => CloseRequested?.Invoke(this);
private bool CanSubmitForReview() => IsTaskBased;
private bool CanSubmitForReview() =>
IsTaskBased && !IsSubmitPending && !Terminal.IsStarting && Terminal.StartError is null && !Terminal.HasExited;
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
private void SubmitForReview()
@@ -89,6 +102,22 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
if (TaskId is { } id) SubmitForReviewRequested?.Invoke(id);
}
partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged();
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
// re-opening the same task would otherwise just re-focus a dead tile.
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
[RelayCommand(CanExecute = nameof(CanRetry))]
private void Retry()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
Terminal.Dispose();
Terminal = new InteractiveTerminalViewModel();
Terminal.PropertyChanged += OnTerminalPropertyChanged;
Start();
}
public void Dispose()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
@@ -20,11 +20,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string, string, string, DateTime> _onTaskFinished;
private readonly Action<string> _onTaskUpdated;
private readonly Action _onConnectionRestored;
private readonly Action<string, IReadOnlyList<string>> _onHandoffRequested;
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
// review/merge/status machinery.
public ObservableCollection<ConPtyPaneViewModel> ConPtySessions { get; } = new();
// Claims a TaskId/listId for the duration of an Open*ConPtySessionAsync call so a second,
// overlapping invocation for the same key (e.g. a double-click) bails out instead of racing
// past the ConPtySessions dedupe check before the first call has added its pane. Checked and
// claimed synchronously at method entry — before any await — and released in a finally.
private readonly HashSet<string> _pendingTaskOpens = new();
private readonly HashSet<string> _pendingMergeHelperLists = new();
// Mirror of ConPtySessions typed as the pane abstraction so the layout toggle (grid/tabs)
// binds one contract rather than a ConPTY-specific type.
public ObservableCollection<IMissionControlPane> Panes { get; } = new();
@@ -77,6 +85,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
_worker.ConnectionRestoredEvent += _onConnectionRestored;
_onHandoffRequested = (taskId, survivingTaskIds) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds); };
_worker.HandoffRequestedEvent += _onHandoffRequested;
_ = RefreshQueueAsync();
}
@@ -111,22 +122,16 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch { /* best-effort queue refresh */ }
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued.
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here
// rather than on the worker side: the worker never touches task status for a UI-hosted ConPTY
// session, so it has no way to know one is open — only Mission Control's own pane list does.
public async System.Threading.Tasks.Task EnqueueTaskAsync(string taskId)
{
if (string.IsNullOrEmpty(taskId)) return;
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId);
if (entity is null
|| entity.Status == ClaudeDo.Data.Models.TaskStatus.Running
|| entity.Status == ClaudeDo.Data.Models.TaskStatus.Queued)
return;
entity.Status = ClaudeDo.Data.Models.TaskStatus.Queued;
await db.SaveChangesAsync();
await _worker.WakeQueueAsync();
}
if (ConPtySessions.Any(s => s.TaskId == taskId)) return;
try { await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued); }
catch { /* best-effort enqueue */ }
await RefreshQueueAsync();
}
@@ -147,18 +152,26 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
FocusedPane = existing;
return;
}
if (!_pendingTaskOpens.Add(taskId)) return;
var title = taskId;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
if (entity?.Title is { Length: > 0 } t) title = t;
}
catch { /* best-effort title lookup */ }
var title = taskId;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
if (entity?.Title is { Length: > 0 } t) title = t;
}
catch { /* best-effort title lookup */ }
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
}
finally
{
_pendingTaskOpens.Remove(taskId);
}
}
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
@@ -172,20 +185,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
FocusedPane = existing;
return;
}
if (!_pendingTaskOpens.Add(taskId)) return;
var title = taskId;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
if (entity?.Title is { Length: > 0 } t) title = t;
}
catch { /* best-effort title lookup */ }
title += Loc.T("missionControl.planningTitleSuffix");
var title = taskId;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
if (entity?.Title is { Length: > 0 } t) title = t;
}
catch { /* best-effort title lookup */ }
title += Loc.T("missionControl.planningTitleSuffix");
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
: _worker.GetPlanningStartLaunchSpecAsync(taskId))));
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
: _worker.GetPlanningStartLaunchSpecAsync(taskId))));
}
finally
{
_pendingTaskOpens.Remove(taskId);
}
}
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
@@ -209,38 +230,81 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
{
if (taskIds is not { Count: > 0 }) return;
// The TaskId dedupe below can't help here — CreateMergeHelperTaskAsync mints a brand-new
// task id every call, so a double-trigger for the same list would always mint two host
// tasks before either pane exists to dedupe against. Guard the whole method per listId.
if (!_pendingMergeHelperLists.Add(listId)) return;
var title = Loc.T("missionControl.mergeHelperTitle");
var listName = listId;
try
{
var title = Loc.T("missionControl.mergeHelperTitle");
var listName = listId;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
if (list?.Name is { Length: > 0 } name) { listName = name; title = $"{title} — {name}"; }
}
catch { /* best-effort title lookup */ }
string taskId;
try
{
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
Loc.T("missionControl.mergeHelperTaskTitle", listName),
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
return;
}
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
{
FocusedPane = existing;
return;
}
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
}
finally
{
_pendingMergeHelperLists.Remove(listId);
}
}
// List-handler handoff: the running session called handoff_list_handler at the end of Phase 2.
// Replaces the Phase 1-2 tile with a fresh one for the SAME handler task id, which carries out
// Phases 3-5. The old tile used to be left open so its last message could still be read, but
// its process is gone by then and the terminal renders empty — it was only ever a dead
// placeholder to close by hand. No new task is created here; see
// InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync.
public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(string taskId, IReadOnlyList<string> survivingTaskIds)
{
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
CloseConPtySession(stale);
var baseTitle = Loc.T("missionControl.mergeHelperTitle");
var title = baseTitle + Loc.T("missionControl.mergeHelperHandoffTitleSuffix");
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
if (list?.Name is { Length: > 0 } name) { listName = name; title = $"{title} — {name}"; }
var task = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
if (task is not null)
{
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == task.ListId);
if (list?.Name is { Length: > 0 } name)
title = $"{baseTitle} — {name}{Loc.T("missionControl.mergeHelperHandoffTitleSuffix")}";
}
}
catch { /* best-effort title lookup */ }
string taskId;
try
{
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
Loc.T("missionControl.mergeHelperTaskTitle", listName),
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
return;
}
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
{
FocusedPane = existing;
return;
}
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds))));
}
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
@@ -266,16 +330,22 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
// session is finished). The worker commits the worktree and moves the task to WaitingForReview.
// Guarded by the pane's IsSubmitPending flag — a rapid double-click would otherwise race two
// SubmitTaskForReviewAsync calls, with the loser flashing a spurious footer error.
private async void OnPaneSubmitForReview(string taskId)
{
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending)
return;
pane.IsSubmitPending = true;
try
{
await _worker.SubmitTaskForReviewAsync(taskId);
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } pane)
CloseConPtySession(pane);
CloseConPtySession(pane);
}
catch (Exception ex)
{
pane.IsSubmitPending = false;
ErrorReported?.Invoke(Loc.T("missionControl.submitForReviewFailed", ex.Message));
}
}
@@ -285,6 +355,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
if (!ConPtySessions.Contains(pane)) return;
pane.ErrorReported -= OnConPtyPaneError;
pane.CloseRequested -= CloseConPtySession;
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
ConPtySessions.Remove(pane);
pane.Dispose();
}
@@ -327,12 +398,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.TaskFinishedEvent -= _onTaskFinished;
_worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
_worker.HandoffRequestedEvent -= _onHandoffRequested;
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
Panes.CollectionChanged -= OnPanesChanged;
foreach (var c in ConPtySessions.ToList())
{
c.ErrorReported -= OnConPtyPaneError;
c.CloseRequested -= CloseConPtySession;
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
c.Dispose();
}
ConPtySessions.Clear();

Some files were not shown because too many files have changed in this diff Show More