51 Commits

Author SHA1 Message Date
mika kuns
cfc45118e4 docs: sync CLAUDE.md files with current architecture
All checks were successful
Release / release (push) Successful in 35s
Drop the removed tag system, fix the retired Manual status and the atomic
queue-claim location, refresh the App DI registrations to the Islands VMs,
update the Data table list, correct a stale test reference, and document the
interface-folder and single-consumer-fold conventions plus the .NET 8 build path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:59:43 +02:00
mika kuns
1856943925 refactor: merge TaskRunner failure handlers and reuse NullIfBlank
Unify the near-identical HandleFailure/MarkFailed into a single MarkFailed that
always persists the failed state and never throws, and replace the inline
null-if-blank checks in ListMcpTools with the existing extension.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:51:14 +02:00
mika kuns
ce9fadc0b5 refactor: fold single-consumer helper types into their owners
Consolidate small single-purpose types into the files that own them:
StreamResult into StreamAnalyzer, the Planning context records into
PlanningSessionContext, PrimeClock/PrimeSchedulerOptions into PrimeScheduler,
AgentMcpTools into LifecycleMcpTools, the locator subclasses into
InstallArtifactLocator, LogLineViewModel into DetailsIslandViewModel,
RepoImportItemViewModel into its modal, and StepViewModel into InstallPageViewModel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:47:46 +02:00
mika kuns
25ee623c42 refactor: remove dead PlanningMergeEvents records and unused RunNowRequestedEvent
The PlanningMergeEvents record types were never instantiated (the broadcaster
uses identically-named methods), and RunNowRequestedEvent had no subscribers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:42:25 +02:00
mika kuns
41da124a31 refactor: extract interfaces to Interfaces folders and consolidate filters
Move interface declarations into per-area Interfaces/ subfolders, merge the
small task-list filter classes into StatusFilter/SmartFlagFilter, and simplify
related services, converters and hub DTO handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:41:10 +02:00
mika kuns
77100b6b3b Merge feat/external-mcp-ui-parity: external MCP UI parity for start/observe
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:37:58 +02:00
mika kuns
32daa4a602 docs(worker): correct external MCP tool inventory, drop removed tag tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:16:48 +02:00
mika kuns
b41a78ec29 feat(worker): register new external MCP tool classes
Wire ListMcpTools, ConfigMcpTools, RunHistoryMcpTools, AgentMcpTools,
LifecycleMcpTools, and AppSettingsMcpTools into the external MCP
container and expose them via WithTools<>().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:15:26 +02:00
mika kuns
9ea60701d2 feat(worker): add external MCP app-settings read tool
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:12:18 +02:00
mika kuns
5a592c4be6 feat(worker): add external MCP reset-failed-task tool
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:07:59 +02:00
mika kuns
7196aab31f feat(worker): add external MCP agent-listing tool
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:05:30 +02:00
mika kuns
fec2fe2dda fix(worker): cap run-log read size and harden run-history tests
- GetTaskLog reads at most last 256 KB; prepends truncation marker if file exceeds cap
- Wrap temp-file cleanup in finally block to prevent leak on assertion failure
- Add GetRun_NotFound_Throws, GetTaskLog_RunExistsButNoLogPath_Throws, and GetTaskLog_LargeFile_ReturnsTruncatedTail tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:04:22 +02:00
mika kuns
3afe29d721 feat(worker): add external MCP run-history and log tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:59:54 +02:00
mika kuns
f3f8af4b11 docs(worker): clarify SetTaskConfig null-clears-override wording
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:57:59 +02:00
mika kuns
c3493a3a74 feat(worker): add external MCP list/task config tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:54:08 +02:00
mika kuns
ac2f1d824e fix(worker): reuse shared hub fake and guard blank list name
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:51:34 +02:00
mika kuns
53f4e2de0f feat(worker): add external MCP list-management tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:47:02 +02:00
mika kuns
99dc08488b docs(worker): add external MCP UI-parity spec and plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:42:24 +02:00
mika kuns
26c4e5771b feat(worker): run worker as per-user logon task instead of Windows service
A LocalSystem Windows service can't see the logged-in user's Claude CLI
authentication, so the worker now runs as the current user via a hidden
per-user logon Scheduled Task with restart-on-failure.

- Worker is WinExe (no console window) with a Serilog rolling file sink and
  a single-instance mutex so the logon task, app ensure-running, and Restart
  button can't fight over the SignalR port.
- Installer replaces the service steps (register/start/stop) with autostart
  task steps, migrates the legacy ClaudeDoWorker service away on update, and
  removes the task on uninstall. ServicePage drops the service-account UI.
- UI gains a WorkerLocator; the app ensures the worker is running at startup
  and the Restart button kills+relaunches this install's worker process.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:41 +02:00
mika kuns
1e5b3a6c3e chore: add .gitattributes to normalize line endings
Default to LF, force CRLF for Windows script/solution files, and mark
common binary types — silences the CRLF-on-commit warnings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:19 +02:00
mika kuns
59d72635da test(ui): rebase IWorkerClient fakes onto shared StubWorkerClient base
Add a StubWorkerClient base implementing the full IWorkerClient surface so
the planning/conflict/diff test fakes only override the members they exercise.
Eliminates the constructor-drift duplication across the three fakes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:12 +02:00
mika kuns
7a88e8a848 fix(ui): apply blue PLANNED badge for finalized planning, drop dead converter statics
Bind the planning-parent badge to IsPlanActive/IsPlanFinalized so a
finalized plan shows the blue "planned" style instead of staying amber.
Remove the unused Instance statics on BoolToItalicConverter and
BoolToDraftOpacityConverter (registered via the App.axaml resource dictionary).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:04 +02:00
mika kuns
b84716ff9c fix(releases): strip prerelease and build metadata before version compare
System.Version can't parse SemVer prerelease ("-alpha") or MinVer build
metadata ("+sha") suffixes, so an installed 1.0.2-alpha was treated as
unparseable. Reduce both sides to their numeric core before comparing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:38:57 +02:00
mika kuns
ce879f6f70 Merge feat/repo-import-polish: remember folders, search, compact rows, no auto-select 2026-05-29 16:39:06 +02:00
mika kuns
2f7f00d4cc docs(ui): clarify repo-import checkbox default intent 2026-05-29 16:31:31 +02:00
mika kuns
6d0973c67c feat(ui): repo-import modal — remember folders, search, compact rows, no auto-select 2026-05-29 16:29:22 +02:00
mika kuns
bb8b3e235a Merge feat/delete-list-button: add delete-list button to List Settings modal 2026-05-29 16:13:32 +02:00
mika kuns
6e3947c0b1 fix(ui): narrow delete-list FK catch to SqliteException 2026-05-29 16:12:15 +02:00
mika kuns
128fb7d4d2 feat(ui): add delete-list button to List Settings modal 2026-05-29 16:09:17 +02:00
mika kuns
3af8fb9aa0 Merge feat/repo-import-list-helper: add repos-as-lists import helper 2026-05-29 15:59:45 +02:00
mika kuns
5b15e30b8a docs: add repo import list helper implementation plan 2026-05-29 15:59:37 +02:00
mika kuns
e5bce07719 docs(ui): document RepoImportModalView 2026-05-29 15:52:34 +02:00
mika kuns
9c638e72b1 feat(ui): add 'Add repos as lists' Help-menu entry point
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:50:52 +02:00
mika kuns
c43b06d83d feat(ui): add repo import button to Lists island
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:46:45 +02:00
mika kuns
d4674cd74e chore(di): register RepoImportModalViewModel 2026-05-29 15:45:04 +02:00
mika kuns
e4d958dcf3 feat(ui): add RepoImportModalView 2026-05-29 15:43:52 +02:00
mika kuns
0f41384fa8 test(ui): assert FullPath in RepoImport candidate test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:42:05 +02:00
mika kuns
50b1589b23 feat(ui): add RepoImportModalViewModel with candidate merge logic 2026-05-29 15:39:43 +02:00
mika kuns
1c689a8472 feat(ui): add RepoImportItemViewModel 2026-05-29 15:37:10 +02:00
mika kuns
4877c11aa2 fix(ui): narrow RepoScanner catch to filesystem exceptions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:36:12 +02:00
mika kuns
03617ee3cd feat(ui): add RepoScanner for git repo discovery
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:34:32 +02:00
mika kuns
7869c2a979 docs: add repo import list helper design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:26:32 +02:00
mika kuns
ce79a2d0fe feat(planning): gate subtask queueing behind plan finalization
Planning subtasks are now "Draft" until their parent plan is finalized,
then "Planned" (queueable). Finalizing a plan no longer auto-queues the
child chain; the user sends the plan to the queue explicitly.

- TaskStateService rejects a child entering Queued/Running unless its parent
  is Finalized; this single invariant covers UI, queue, RunNow and MCP paths
- WorkerHub.SetTaskStatus routes Queued through the gated EnqueueAsync
- Finalize call sites pass queueAgentTasks: false
- PlanningChainCoordinator.QueuePlanAsync guards the chain build on Finalized
- TaskRowViewModel derives Draft/Planned from ParentFinalized; gates
  CanSendToQueue / CanQueuePlan; view shows a PLANNED badge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:41:48 +02:00
mika kuns
09a930e28e docs: add planning draft/planned queue gate design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:25:52 +02:00
mika kuns
c1c7862672 fix(ui): widen About modal so folder Open buttons are not clipped
Long folder paths in monospace pushed the Open buttons past the 480px
window edge. Widen to 620px, disable horizontal scroll so paths trim, and
add column spacing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:25:52 +02:00
mika kuns
19f22d2d97 chore(ui): clear build warnings
- Guard Windows-only ServiceController/registry calls behind SupportedOSPlatform
  and OperatingSystem.IsWindows() (CA1416)
- Initialize test-only ctor fields with null! (CS8618)
- Migrate obsolete Avalonia APIs: Watermark -> PlaceholderText,
  SystemDecorations -> WindowDecorations

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:25:44 +02:00
mika kuns
12668f684f fix(ui): restore Ui.Tests build by implementing ListUpdatedEvent in fakes
The IWorkerClient.ListUpdatedEvent member was added without updating three
test fakes, breaking compilation of the Ui.Tests project.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:25:33 +02:00
mika kuns
967e0cd319 feat(ui): merge action and robust jump-to-task in worktrees overview
Add Merge entry to the worktrees overview context menu wiring the existing
MergeModalViewModel, replace fire-and-forget list selection with a
collection-change-aware JumpToTaskHelper, and propagate list renames to
visible task rows via a new ListUpdated event.

Harden worktree state changes: WorkerHub.SetWorktreeState now rejects
invalid transitions, WorktreeMaintenanceService only drops the DB row when
the on-disk worktree was actually removed, and Cleanup/Reset broadcast
WorktreeUpdated for affected tasks. SetWorktreeStateAsync returns the hub
error message so the modal can surface it.

Also: de-duplicate the worktrees overview modal opener, hook
OnParentTaskIdChanged to refresh IsDraft, fix MergeModal CanExecute
notifications, and add WorktreeStateHubTests for the transition rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:43:39 +02:00
mika kuns
2223839595 feat(ui): hide list chip outside virtual list views
Task rows now expose a ShowListChip flag that the tasks island sets
only for Virtual list kinds, so the chip stops being redundant when
viewing a single concrete list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:27:04 +02:00
mika kuns
7d61d38a34 fix(ui): dispatch WorkerLog events to UI thread
Worker log broadcasts arrive on a SignalR thread; raising the event
directly let UI subscribers touch bindable state off-thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:26:57 +02:00
mika kuns
e55367af67 fix(ui): wire details-island buttons and drop dead handlers
- Bind star button to ToggleStarCommand; wrap header and subtask
  done-check ellipses in buttons (ToggleDone / ToggleSubtaskDone).
- Wire AgentStrip copy-path button to clipboard handler.
- Remove dead Notes/PromptInput/ApproveMerge/ShowWorktreeModal code
  with no UI bindings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:24:51 +02:00
153 changed files with 6061 additions and 1218 deletions

16
.gitattributes vendored Normal file
View File

@@ -0,0 +1,16 @@
* text=auto eol=lf
*.sln text eol=crlf
*.slnx text eol=crlf
*.cmd text eol=crlf
*.bat text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.exe binary
*.dll binary

View File

@@ -35,18 +35,23 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- 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: Manual | Queued -> Running -> Done | Failed
- Task status flow: Idle | Queued -> Running -> Done | Failed | Cancelled
- Worktree state flow: Active -> Merged | Discarded | Kept
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
- 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)
- Small single-consumer helper types live in their consumer's file, not standalone files
- Commit messages use conventional format: `{commitType}(slug): title`
- Views use compiled bindings (`x:DataType`)
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
## Building & Testing
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects instead.
```bash
dotnet build ClaudeDo.slnx
dotnet test tests/ClaudeDo.Worker.Tests
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj # pulls in Ui + Data
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet test tests/ClaudeDo.Worker.Tests # also: Data.Tests, Ui.Tests, Installer.Tests, Releases.Tests
```
## Docs

View File

@@ -65,9 +65,9 @@ Bedingt durch Slice "Planning B/C". Ablauf identisch zur alten open.md:
8. Delete-Versuch auf Parent mit Children → freundlicher Fehlerdialog, kein Delete.
**Bekannte Follow-ups (non-blocking):**
- `Border.badge.planned` (blau) ist in `IslandStyles.axaml` definiert, wird aber nie angewendet — `TaskRowView` behält die `planning`-Klasse für `Active` UND `Finalized`, daher amber statt blau bei finalisiert. Entweder Class-Swap auf `planned` bei `Finalized`, oder die unused Style+Brush entfernen.
- Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` `App.axaml` registriert via Resource-Dictionary, die statischen Members können weg.
- `Ui.Tests` IWorkerClient-Fakes (`DetailsIslandPlanningTests`, `PlanningDiffViewModelTests`, `ConflictResolutionViewModelTests`) fehlen `OpenInteractiveTerminalAsync` und `QueuePlanningSubtasksAsync` — Constructor-Drift, Fakes auf gemeinsame abstrakte Basis rebasen.
- `Border.badge.planned` (blau) wird jetzt bei `Finalized` angewendet — `TaskRowView` nutzt `Classes.planning`/`Classes.planned` gebunden an `IsPlanActive`/`IsPlanFinalized`; der Child-„PLANNED"-Badge nutzt direkt `planned`.
- Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` entfernt (Registrierung läuft über das Resource-Dictionary in `App.axaml`).
- `Ui.Tests` IWorkerClient-Fakes auf gemeinsame Basis `StubWorkerClient` rebased — kein Constructor-Drift mehr; die drei Fakes überschreiben nur ihre relevanten Member.
### 1.2 Prime Claude — Manual Verification
@@ -129,11 +129,11 @@ Voraussetzung: funktionierendes Gitea-Release unter `git.kuns.dev/releases/Claud
### 2.7 Settings-Dialog ✅
- `SettingsModalView` als `TabControl`, Tabs: General, Prime Claude, etc. Persistiert in `~/.todo-app/ui.config.json` und `worker.config.json`.
### 2.8 (NEU) Planning-Phase Badge-Farbe für `Finalized`
Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht angewendet.
### 2.8 (NEU) Planning-Phase Badge-Farbe für `Finalized`
`Finalized` zeigt jetzt den blauen `planned`-Badge (Class-Binding in `TaskRowView`).
### 2.9 (NEU) Tote Converter-Statics entfernen
`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` — siehe §1.1.
### 2.9 (NEU) Tote Converter-Statics entfernen
`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` entfernt.
---
@@ -161,8 +161,11 @@ Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht ang
## 4. Service-Deployment
### 4.1 Windows-Service-Hosting ✅
- `Program.cs` ruft `builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker")`.
### 4.1 Worker-Autostart als Per-User-Task ✅ (ersetzt Windows-Service)
- Der Worker läuft **nicht mehr als Windows-Service** (LocalSystem konnte die Claude-CLI-Auth des Users nicht sehen). Stattdessen: per-user **Logon-Scheduled-Task** „ClaudeDoWorker" (`schtasks /Create /XML`), läuft als angemeldeter User, versteckt, mit Restart-on-Failure.
- Worker ist `WinExe` (kein Konsolenfenster) + Serilog-File-Sink (`~/.todo-app/logs/worker-*.log`) + Single-Instance-Mutex.
- Installer migriert beim Update den alten Service automatisch weg (`sc stop`/`delete`) und registriert die Task; Uninstall entfernt Task + Worker-Prozess. App startet/neustartet den Worker als Prozess und sorgt beim Start dafür, dass er läuft.
- Implementiert 2026-05-29, getestet (Build + Unit-Tests grün), **manuelle E2E-Verifikation am Gerät ausstehend** (Update von 1.0.2-alpha → Task, Logoff/Logon-Autostart, Uninstall).
### 4.2 Pfad-Auflösung absolut ✅
- `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder.
@@ -220,13 +223,13 @@ Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht ang
| Stelle | Issue | Status |
|---|---|---|
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | |
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ✅ (gibt bereits `IReadOnlyList<ActiveTaskDto>` zurück) |
| `TaskRunner` führt eine `if (list.WorkingDir != null)`-Verzweigung mitten in der Methode | Strategy-Pattern wenn die Methode wächst, aktuell noch klein genug | ⬜ |
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern, toleriert weil nur in `App.OnFrameworkInitializationCompleted` | ⬜ |
| Embedded `schema.sql` ohne Versionierung | Durch EF-Core-Migrationen ersetzt | ✅ |
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | |
| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | |
| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | |
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | ✅ (`.gitattributes` angelegt) |
| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | ✅ (entfernt) |
| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | ✅ (im Main-Code nicht mehr vorhanden) |
---

View File

@@ -0,0 +1,834 @@
# Repo Import List Helper Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a helper that scans parent folders for git repos and bulk-creates lists (with `WorkingDir` pre-filled) for the repos the user ticks.
**Architecture:** A pure `RepoScanner` finds git repos under a parent folder. A `RepoImportModalViewModel` loads existing lists' working dirs, merges scanned candidates into a checklist (marking already-added repos), and creates `ListEntity` rows for ticked-new repos via `ListRepository`. `RepoImportModalView` hosts the checklist and a folder picker. Two entry points open the modal: a Help-menu item (handled by `IslandsShellViewModel`) and a folder button in the Lists island (handled by `ListsIslandViewModel`). Each entry point reloads the Lists island after the modal closes.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, EF Core (SQLite), xUnit.
---
## File Structure
**Create:**
- `src/ClaudeDo.Ui/Services/RepoScanner.cs` — pure filesystem scan; `RepoCandidate` record + `RepoScanner.Scan`.
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs` — one checklist row.
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs` — modal VM (load, merge, create) + static `BuildCandidates`.
- `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml` (+ `.axaml.cs`) — modal window + folder picker.
- `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs` — scanner unit tests.
- `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs` — merge/dedupe/already-added unit tests.
**Modify:**
- `src/ClaudeDo.App/Program.cs` — register `RepoImportModalViewModel` (transient) + a `Func<RepoImportModalViewModel>`; pass the Func into `IslandsShellViewModel`.
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs``ShowRepoImportModal` Func + `OpenRepoImportCommand`.
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` — folder button beside `+ New list`.
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` — wire `ShowRepoImportModal`.
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs``ShowRepoImportModal` Func + `OpenRepoImportCommand`; inject `Func<RepoImportModalViewModel>`.
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — Help-menu item `Add repos as lists…`.
- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowRepoImportModal`.
- `src/ClaudeDo.Ui/CLAUDE.md` — document the new modal + entry points.
---
## Task 1: RepoScanner
**Files:**
- Create: `src/ClaudeDo.Ui/Services/RepoScanner.cs`
- Test: `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoScannerTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "repo-scan-" + Guid.NewGuid().ToString("N"));
public RepoScannerTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private string MakeDir(string name)
{
var p = Path.Combine(_root, name);
Directory.CreateDirectory(p);
return p;
}
[Fact]
public void Scan_ReturnsSubfoldersWithGitDirectory()
{
var repo = MakeDir("repo-a");
Directory.CreateDirectory(Path.Combine(repo, ".git"));
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("repo-a", result[0].Name);
Assert.Equal(repo, result[0].FullPath);
}
[Fact]
public void Scan_TreatsDotGitFileAsRepo()
{
var repo = MakeDir("worktree-repo");
File.WriteAllText(Path.Combine(repo, ".git"), "gitdir: ../somewhere");
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("worktree-repo", result[0].Name);
}
[Fact]
public void Scan_IgnoresPlainFolders()
{
MakeDir("not-a-repo");
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_IsNotRecursive()
{
var nested = MakeDir(Path.Combine("outer", "inner"));
Directory.CreateDirectory(Path.Combine(nested, ".git"));
// outer itself has no .git
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_ReturnsEmptyForMissingFolder()
{
var result = RepoScanner.Scan(Path.Combine(_root, "does-not-exist"));
Assert.Empty(result);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
Expected: FAIL — `RepoScanner` / `RepoCandidate` do not exist (compile error).
- [ ] **Step 3: Implement RepoScanner**
Create `src/ClaudeDo.Ui/Services/RepoScanner.cs`:
```csharp
namespace ClaudeDo.Ui.Services;
public sealed record RepoCandidate(string Name, string FullPath);
public static class RepoScanner
{
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); }
catch { 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));
}
return result;
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Services/RepoScanner.cs tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs
git commit -m "feat(ui): add RepoScanner for git repo discovery"
```
---
## Task 2: RepoImportItemViewModel
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`
No dedicated test (trivial display VM; covered indirectly by Task 3).
- [ ] **Step 1: Implement the item VM**
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`:
```csharp
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportItemViewModel : ViewModelBase
{
public string Name { get; init; } = "";
public string FullPath { get; init; } = "";
// True when a list already points at this path. Such rows are shown ticked + disabled.
public bool AlreadyAdded { get; init; }
public bool CanToggle => !AlreadyAdded;
[ObservableProperty] private bool _isChecked;
}
```
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
git commit -m "feat(ui): add RepoImportItemViewModel"
```
---
## Task 3: RepoImportModalViewModel
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`
The pure `BuildCandidates` static method is the tested seam (dedupe + already-added marking). `LoadAsync`/`CreateAsync` touch the DB and are verified manually.
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoImportCandidatesTests
{
[Fact]
public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].IsChecked);
Assert.False(items[0].AlreadyAdded);
Assert.Equal("repo-a", items[0].Name);
}
[Fact]
public void BuildCandidates_ExistingWorkingDir_IsMarkedAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].AlreadyAdded);
Assert.True(items[0].IsChecked); // already-added rows render ticked
}
[Fact]
public void BuildCandidates_SkipsPathsAlreadyShown()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Empty(items);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
Expected: FAIL — `RepoImportModalViewModel` does not exist (compile error).
- [ ] **Step 3: Implement the modal VM**
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`:
```csharp
using System.Collections.ObjectModel;
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportModalViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
public Action? CloseAction { get; set; }
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
public bool CanCreate => CreateCount > 0;
public string CreateButtonText => $"Create {CreateCount} list(s)";
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task LoadAsync(CancellationToken ct = default)
{
Repos.Clear();
_existingDirs.Clear();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
foreach (var l in await lists.GetAllAsync(ct))
{
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
_existingDirs.Add(l.WorkingDir!);
}
NotifyCreateState();
}
public void AddFolders(IEnumerable<string> folders)
{
var current = new HashSet<string>(
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
foreach (var folder in folders)
{
var found = RepoScanner.Scan(folder);
foreach (var item in BuildCandidates(found, current, _existingDirs))
{
item.PropertyChanged += OnItemChanged;
Repos.Add(item);
current.Add(item.FullPath);
}
}
NotifyCreateState();
}
public static List<RepoImportItemViewModel> BuildCandidates(
IEnumerable<RepoCandidate> found,
IReadOnlySet<string> currentPaths,
IReadOnlySet<string> existingDirs)
{
var items = new List<RepoImportItemViewModel>();
foreach (var c in found)
{
if (currentPaths.Contains(c.FullPath)) continue;
items.Add(new RepoImportItemViewModel
{
Name = c.Name,
FullPath = c.FullPath,
AlreadyAdded = existingDirs.Contains(c.FullPath),
IsChecked = true,
});
}
return items;
}
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
NotifyCreateState();
}
private void NotifyCreateState()
{
OnPropertyChanged(nameof(CreateCount));
OnPropertyChanged(nameof(CanCreate));
OnPropertyChanged(nameof(CreateButtonText));
}
[RelayCommand]
private async Task CreateAsync()
{
var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList();
if (toCreate.Count > 0)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
foreach (var r in toCreate)
{
await lists.AddAsync(new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = r.Name,
WorkingDir = r.FullPath,
DefaultCommitType = CommitTypeRegistry.DefaultType,
CreatedAt = DateTime.UtcNow,
});
}
}
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
git commit -m "feat(ui): add RepoImportModalViewModel with candidate merge logic"
```
---
## Task 4: RepoImportModalView
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`
Modeled on `AboutModalView.axaml` (header/body/footer) and `ListSettingsModalView.axaml.cs` (folder picker).
- [ ] **Step 1: Create the view XAML**
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`:
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
x:DataType="vm:RepoImportModalViewModel"
Title="Add repos as lists"
Width="560" Height="480"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
<Grid RowDefinitions="36,Auto,*,52">
<!-- Header -->
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="ADD REPOS AS LISTS" FontFamily="{DynamicResource MonoFont}" FontSize="11"
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
Command="{Binding CancelCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Add folder row -->
<Border Grid.Row="1" Padding="16,12,16,4">
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
</Border>
<!-- Repo checklist -->
<ScrollViewer Grid.Row="2" Padding="16,4,16,8">
<ItemsControl ItemsSource="{Binding Repos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:RepoImportItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
<CheckBox Grid.Column="0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding CanToggle}"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Margin="6,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource TextBrush}" FontSize="13"/>
<TextBlock Text="{Binding FullPath}" Foreground="{DynamicResource TextFaintBrush}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="(already added)"
Foreground="{DynamicResource TextFaintBrush}" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding AlreadyAdded}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="3" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="accent"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>
```
- [ ] **Step 2: Create the code-behind with folder picker**
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`:
```csharp
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class RepoImportModalView : Window
{
public RepoImportModalView()
{
InitializeComponent();
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not RepoImportModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose folders containing repos",
AllowMultiple = true,
});
if (folders.Count == 0) return;
vm.AddFolders(folders.Select(f => f.Path.LocalPath));
}
}
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded. (`TitleBar_PointerPressed` is unused for now but kept for parity with other modals; if the build warns as error, leave it — other modals keep the same handler.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
git commit -m "feat(ui): add RepoImportModalView"
```
---
## Task 5: DI registration
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs:106` (after `ListSettingsModalViewModel` registration)
- [ ] **Step 1: Register the modal VM and its factory**
In `src/ClaudeDo.App/Program.cs`, after the line `sc.AddTransient<ListSettingsModalViewModel>();` add:
```csharp
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
```
(`RepoImportModalViewModel` is in namespace `ClaudeDo.Ui.ViewModels.Modals`, already imported in `Program.cs` via the existing modal VM usings — verify the using is present; if not, add `using ClaudeDo.Ui.ViewModels.Modals;`.)
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.App/Program.cs
git commit -m "chore(di): register RepoImportModalViewModel"
```
---
## Task 6: Lists island entry point
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`
- [ ] **Step 1: Add Func + command to the VM**
In `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`, next to the existing `ShowListSettingsModal` property (around line 30), add:
```csharp
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
```
Then add a command (place it near `CreateListAsync`, e.g. after the `OpenWorktreesOverviewAsync` command around line 71):
```csharp
[RelayCommand]
private async System.Threading.Tasks.Task OpenRepoImportAsync()
{
if (ShowRepoImportModal is null || _services is null) return;
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
await LoadAsync();
}
```
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported at the top of this file.)
- [ ] **Step 2: Add the folder button in XAML**
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`, replace the existing `+ New list` button block (lines 171-183) with a row that holds both the new-list button and a folder-scan button:
```xml
<!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="New list" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding OpenRepoImportCommand}"
ToolTip.Tip="Add repos as lists">
<PathIcon Data="{StaticResource Icon.Folder}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid>
```
- [ ] **Step 3: Wire the Func in the code-behind**
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`, inside the `DataContextChanged` handler (after the `vm.ShowWorktreesOverviewModal = ...` assignment, before the closing brace of the `if` block around line 66), add:
```csharp
vm.ShowRepoImportModal = async modal =>
{
var window = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
```
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
- [ ] **Step 4: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs
git commit -m "feat(ui): add repo import button to Lists island"
```
---
## Task 7: Help-menu entry point
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.App/Program.cs` (pass the Func into the shell VM)
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
- [ ] **Step 1: Add Func, factory field, and command to the shell VM**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
(a) Near the `ShowAboutModal` property (line 44), add:
```csharp
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
```
(b) Add a backing field for the factory next to `_worktreesOverviewVmFactory` (declared as a private readonly field elsewhere in the class). Add:
```csharp
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
```
(c) Add a parameter to the public constructor (line 162-171) — append after `mergeVmFactory`:
```csharp
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory)
```
and in the constructor body assign it (next to `_mergeVmFactory = mergeVmFactory;`):
```csharp
_repoImportVmFactory = repoImportVmFactory;
```
(d) Add the command near `OpenAbout` (line 256):
```csharp
[RelayCommand]
private async Task OpenRepoImport()
{
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
var vm = _repoImportVmFactory();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
if (Lists is not null) await Lists.LoadAsync();
}
```
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported in this file.)
- [ ] **Step 2: Pass the Func into the shell VM in DI**
`IslandsShellViewModel` is registered with `sc.AddSingleton<IslandsShellViewModel>();` (Program.cs:123), which resolves constructor params from the container. Since Task 5 registered `Func<RepoImportModalViewModel>`, no change to the registration call is required — the new constructor parameter resolves automatically. Verify by building in Step 5.
- [ ] **Step 3: Add the Help-menu item**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, inside the Help `MenuItem` (after the `About…` item at line 74), add:
```xml
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
```
- [ ] **Step 4: Wire the Func in MainWindow code-behind**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged` (after the `vm.ShowWorktreesOverviewModal = ...` block, before the closing brace of the `if` at line 65), add:
```csharp
vm.ShowRepoImportModal = async (modal) =>
{
var dlg = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
```
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded (this also builds the Ui project).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): add 'Add repos as lists' Help-menu entry point"
```
---
## Task 8: Manual verification + docs
**Files:**
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- [ ] **Step 1: Run the full Ui test suite**
Run: `dotnet test tests/ClaudeDo.Ui.Tests`
Expected: PASS (all tests, including the new `RepoScannerTests` and `RepoImportCandidatesTests`).
- [ ] **Step 2: Manual smoke test**
Launch the app (`dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`). Verify:
- Lists island shows a folder button next to `+ New list`; clicking it opens the modal.
- Help menu shows `Add repos as lists…`; clicking it opens the same modal.
- `Add folder…` → pick a parent folder containing git repos → repos appear as ticked rows; non-repo subfolders are absent.
- A repo that already has a list appears ticked, disabled, with `(already added)`.
- The confirm button reads `Create N list(s)` and is disabled when N is 0.
- Confirming creates the lists; they appear in the Lists island immediately after the modal closes.
Note: if you cannot run the GUI in this environment, state that explicitly rather than claiming the UI works.
- [ ] **Step 3: Update CLAUDE.md**
In `src/ClaudeDo.Ui/CLAUDE.md`, under the `## Views` section, add a bullet:
```markdown
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
```
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs(ui): document RepoImportModalView"
```
---
## Self-Review Notes
- **Spec coverage:** Entry points (Help menu — Task 7; Lists island button — Task 6); `RepoScanner` non-recursive `.git` dir/file detection (Task 1); `RepoImportModalViewModel` load existing dirs + merge + create (Task 3); already-added disabled rows + `(already added)` label (Tasks 2/3/4); combined multi-folder checklist with path dedupe (Task 3 `AddFolders`); defaults Name/WorkingDir/DefaultCommitType (Task 3 `CreateAsync`); reload Lists island after close (Tasks 6/7); DI registration (Task 5); tests for scanner + merge logic (Tasks 1/3). All spec sections map to a task.
- **Type consistency:** `RepoCandidate(Name, FullPath)`, `RepoScanner.Scan`, `RepoImportItemViewModel{Name,FullPath,AlreadyAdded,CanToggle,IsChecked}`, `RepoImportModalViewModel{Repos,CreateCount,CanCreate,CreateButtonText,LoadAsync,AddFolders,BuildCandidates,CreateCommand,CancelCommand,ShowRepoImportModal,CloseAction}` used consistently across tasks.
- **YAGNI:** No recursive scan, no inline rename, no per-list model/prompt/agent during import — all explicitly out of scope.

View File

@@ -0,0 +1,655 @@
# Worker Per-User Autostart Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the worker's Windows service with a per-user logon Scheduled Task so the worker runs as the logged-in user (Claude auth works), windowless, with file logging and auto-restart.
**Architecture:** Worker becomes a windowless (`WinExe`) process with Serilog file logging and a single-instance mutex. The installer registers a hidden logon Scheduled Task (via `schtasks /Create /XML`), migrates away the old `ClaudeDoWorker` service, and manages the worker as a process. The app launches/restarts the worker as a process and ensures it's running.
**Tech Stack:** .NET 8, ASP.NET Core (worker), WPF (installer), Avalonia (app), Serilog, Windows Task Scheduler (`schtasks`), `sc.exe`.
**Build note:** `.slnx` fails on .NET 8 — always build individual `.csproj` files.
---
## File Structure
**Worker**
- Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — WinExe, Serilog packages, drop Hosting.WindowsServices.
- Modify `src/ClaudeDo.Worker/Program.cs` — mutex, Serilog, remove `UseWindowsService`.
**Installer**
- Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` — pure XML builder.
- Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` — migrate service + register task.
- Rename/rewrite `StopServiceStep.cs``StopWorkerStep.cs`, `StartServiceStep.cs``StartWorkerStep.cs`.
- Delete `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`.
- Modify `Pages/ServicePage/ServicePageViewModel.cs` + `ServicePageView.xaml` — drop account radios.
- Modify `Core/InstallContext.cs` — drop `ServiceAccount`.
- Modify `Pages/InstallPage/InstallPageViewModel.cs` — pipeline wiring.
- Modify `App.xaml.cs` — DI registration.
- Modify `Core/UninstallRunner.cs` — task delete + process kill.
- Modify `Views/SettingsViewModel.cs` — use renamed steps.
**App**
- Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs` — resolve worker exe path.
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — process restart + ensure-running.
- Modify `src/ClaudeDo.App/Program.cs` — register `WorkerLocator`, pass to shell VM if needed.
**Tests**
- Create `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`.
- Create `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`.
---
## Task 1: Worker → WinExe + Serilog packages
**Files:** Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- [ ] **Step 1:** In the main `<PropertyGroup>` add `<OutputType>WinExe</OutputType>`. Remove the `Microsoft.Extensions.Hosting.WindowsServices` PackageReference. Add:
```xml
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
```
- [ ] **Step 2:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds (packages restore).
---
## Task 2: Worker single-instance mutex + Serilog + drop UseWindowsService
**Files:** Modify `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1:** At the very top of the file (before `var cfg = WorkerConfig.Load();`), add the single-instance guard:
```csharp
using System.Threading;
// Single-instance per user session. Multiple launch paths exist (logon task,
// app ensure-running, Restart button); a second instance exits cleanly instead
// of fighting over the SignalR port.
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
if (!createdNew)
return; // another instance already owns the port; exit 0
```
- [ ] **Step 2:** Remove the `builder.Host.UseWindowsService(...)` line (lines ~21-23 incl. the comment).
- [ ] **Step 3:** After `var builder = WebApplication.CreateBuilder(args);`, add Serilog file logging:
```csharp
using Serilog;
var logRoot = ClaudeDo.Data.Paths.Expand(cfg.LogRoot);
Directory.CreateDirectory(logRoot);
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true));
```
(If `cfg.LogRoot` is already absolute/expanded, `Paths.Expand` is a safe no-op. Verify `WorkerConfig` exposes `LogRoot`; if the property differs, use the actual name.)
- [ ] **Step 4:** At the very end of the file, after the run block, add `GC.KeepAlive(mutex);` to ensure the mutex isn't collected.
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds.
- [ ] **Step 6:** Run worker tests: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Expected: all pass (set `CLAUDEDO_SKIP_CLI_PREFLIGHT=1` if needed; existing tests already handle this).
---
## Task 3: Scheduled-task XML builder (pure, TDD)
**Files:** Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`, Test `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`
- [ ] **Step 1: Write the failing test:**
```csharp
using ClaudeDo.Installer.Core;
using Xunit;
namespace ClaudeDo.Installer.Tests;
public class ScheduledTaskXmlTests
{
[Fact]
public void Build_EmbedsUserExeAndLogonTrigger()
{
var xml = ScheduledTaskXml.Build(
userId: "MACHINE\\mika",
workerExePath: @"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe",
restartIntervalMinutes: 1);
Assert.Contains("<LogonTrigger>", xml);
Assert.Contains("<UserId>MACHINE\\mika</UserId>", xml);
Assert.Contains("<LogonType>InteractiveToken</LogonType>", xml);
Assert.Contains("<Hidden>true</Hidden>", xml);
Assert.Contains("<RunLevel>LeastPrivilege</RunLevel>", xml);
Assert.Contains(@"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", xml);
Assert.Contains("<Interval>PT1M</Interval>", xml);
}
[Fact]
public void Build_ClampsRestartIntervalToOneMinuteMinimum()
{
var xml = ScheduledTaskXml.Build("M\\u", @"C:\w.exe", restartIntervalMinutes: 0);
Assert.Contains("<Interval>PT1M</Interval>", xml);
}
}
```
- [ ] **Step 2: Run it, verify fail:** `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj --filter ScheduledTaskXmlTests` — Expected: FAIL (type missing).
- [ ] **Step 3: Implement:**
```csharp
using System.Security;
namespace ClaudeDo.Installer.Core;
/// <summary>Builds a Task Scheduler definition XML for the per-user worker autostart.
/// Pure function so it can be unit-tested without admin rights.</summary>
public static class ScheduledTaskXml
{
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
{
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
var user = SecurityElement.Escape(userId);
var cmd = SecurityElement.Escape(workerExePath);
return $"""
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>ClaudeDo background worker (per-user).</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{user}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<Hidden>true</Hidden>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<RestartOnFailure>
<Interval>PT{minutes}M</Interval>
<Count>3</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{cmd}</Command>
</Exec>
</Actions>
</Task>
""";
}
}
```
- [ ] **Step 4: Run, verify pass:** same filter — Expected: PASS.
---
## Task 4: RegisterAutostartStep (migrate service + register task)
**Files:** Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`
- [ ] **Step 1: Implement** (no unit test — shells out to `sc`/`schtasks`; logic kept thin):
```csharp
using System.IO;
using System.Security.Principal;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterAutostartStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
private const string LegacyServiceName = "ClaudeDoWorker";
public string Name => "Register Autostart";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// 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);
if (queryExit == 0)
{
progress.Report("Removing legacy worker service...");
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (q != 0) break;
await Task.Delay(1000, ct);
}
}
// 2) Register (or replace) the per-user logon task.
var userId = WindowsIdentity.GetCurrent().Name;
var minutes = Math.Max(1, ctx.RestartDelayMs / 60000);
var xml = ScheduledTaskXml.Build(userId, workerExe, minutes);
var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml");
await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct);
try
{
progress.Report("Registering logon task...");
var (exit, output) = await ProcessRunner.RunAsync(
"schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}");
}
finally
{
try { File.Delete(xmlPath); } catch { /* best effort */ }
}
return StepResult.Ok();
}
}
```
- [ ] **Step 2: Build:** `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds (after Task 5/6 it compiles fully; if `RestartDelayMs` exists on `InstallContext` already, this compiles now).
---
## Task 5: StopWorkerStep + StartWorkerStep (replace service steps)
**Files:** Create `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`. Delete `StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
- [ ] **Step 1: Create `StopWorkerStep.cs`:**
```csharp
using System.Diagnostics;
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public const string ProcessName = "ClaudeDo.Worker";
public string Name => "Stop Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Stopping worker task (if running)...");
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
progress.Report("Stopping worker process (if running)...");
var installDir = ctx.InstallDirectory;
foreach (var p in Process.GetProcessesByName(ProcessName))
{
try
{
var path = p.MainModule?.FileName;
if (path is not null && !IsUnder(path, installDir)) continue;
p.Kill(entireProcessTree: true);
p.WaitForExit(10000);
}
catch { /* process may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static bool IsUnder(string filePath, string dir)
{
try
{
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
var full = Path.GetFullPath(filePath);
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
}
```
- [ ] **Step 2: Create `StartWorkerStep.cs`:**
```csharp
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public string Name => "Start Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Starting worker...");
var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}");
return StepResult.Ok();
}
}
```
- [ ] **Step 3:** Delete `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
- [ ] **Step 4:** Grep for remaining references: `StopServiceStep`, `StartServiceStep`, `RegisterServiceStep` across `src/` — fix each (Tasks 6-9 cover them).
---
## Task 6: InstallContext + ServicePage cleanup
**Files:** Modify `src/ClaudeDo.Installer/Core/InstallContext.cs`, `Pages/ServicePage/ServicePageViewModel.cs`, `Pages/ServicePage/ServicePageView.xaml`
- [ ] **Step 1:** In `InstallContext.cs` remove the `ServiceAccount` property (keep `AutoStart`, `RestartDelayMs`, `SignalRPort`, `ClaudeBin`, etc.).
- [ ] **Step 2:** In `ServicePageViewModel.cs` remove `IsLocalSystem`/`IsCurrentUser` `[ObservableProperty]` fields and the `_context.ServiceAccount = ...` line in `ApplyAsync`. Keep port/claudeBin/autostart/restartDelay.
- [ ] **Step 3:** In `ServicePageView.xaml` remove the radio buttons / account-selection UI bound to those properties. Leave the rest.
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 7-9.
---
## Task 7: Pipeline wiring + DI
**Files:** Modify `Pages/InstallPage/InstallPageViewModel.cs`, `App.xaml.cs`
- [ ] **Step 1:** In `InstallPageViewModel.LoadAsync`, update the **Update** display steps to:
```csharp
Steps.Add(new StepViewModel("Stop Worker"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Start Worker"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
```
And the **Fresh** display steps to:
```csharp
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Start Worker"));
```
- [ ] **Step 2:** In `RunInstallAsync`, set the Update execution list to:
```csharp
steps = new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopWorkerStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
_serviceProvider.GetRequiredService<StartWorkerStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
};
```
- [ ] **Step 3:** In `App.xaml.cs` `BuildServices`, replace the service-step registrations. Fresh-install `IInstallStep` order must be: Download, WriteConfig, InitDatabase, **RegisterAutostart**, CreateShortcuts, WriteUninstallRegistry, WriteInstallManifest, **StartWorker**. Register:
```csharp
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<RegisterAutostartStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<WriteUninstallRegistryStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
sc.AddSingleton<StartWorkerStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
// Not part of the default fresh IEnumerable<IInstallStep> — pulled individually.
sc.AddSingleton<StopWorkerStep>();
```
Remove old `StopServiceStep`/`StartServiceStep`/`RegisterServiceStep` registrations.
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 8-9.
---
## Task 8: SettingsViewModel + UninstallRunner
**Files:** Modify `Views/SettingsViewModel.cs`, `Core/UninstallRunner.cs`
- [ ] **Step 1:** In `SettingsViewModel.cs`, change ctor params/fields `StopServiceStep`/`StartServiceStep``StopWorkerStep`/`StartWorkerStep` (rename type usages only; the Save/Repair logic stays). Update the `Repair` step array to `{ _stopWorker, _downloadStep, _startWorker }`.
- [ ] **Step 2:** In `UninstallRunner.cs`:
- Constructor param `StopServiceStep``StopWorkerStep` (field too).
- Replace `sc.exe delete ClaudeDoWorker` with task removal + legacy service cleanup:
```csharp
// 3) Unregister autostart task + remove any legacy service.
progress.Report("Removing autostart task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct); // legacy, best-effort
```
- The existing `_stopService.ExecuteAsync` call becomes `_stopWorker.ExecuteAsync` (kills the worker process before deleting files).
- [ ] **Step 3:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: **succeeds, 0 errors**.
- [ ] **Step 4:** Run installer tests: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` — Expected: all pass (incl. new `ScheduledTaskXmlTests`).
---
## Task 9: App WorkerLocator (TDD)
**Files:** Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs`, Test `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`
- [ ] **Step 1: Write failing test:**
```csharp
using ClaudeDo.Ui.Services;
using Xunit;
namespace ClaudeDo.Ui.Tests.Services;
public class WorkerLocatorTests
{
[Fact]
public void FindByWalkingUp_FindsWorkerExeBesideInstallJson()
{
var root = Path.Combine(Path.GetTempPath(), "claudedo_wl_" + Guid.NewGuid().ToString("N"));
var appDir = Path.Combine(root, "app");
var workerDir = Path.Combine(root, "worker");
Directory.CreateDirectory(appDir);
Directory.CreateDirectory(workerDir);
File.WriteAllText(Path.Combine(root, "install.json"), "{}");
var exe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(exe, "");
try
{
var found = new WorkerLocator().FindByWalkingUp(appDir);
Assert.Equal(exe, found);
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public void FindByWalkingUp_ReturnsNullWhenNoManifest()
{
var dir = Path.Combine(Path.GetTempPath(), "claudedo_wl_none_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try { Assert.Null(new WorkerLocator().FindByWalkingUp(dir)); }
finally { Directory.Delete(dir, recursive: true); }
}
}
```
- [ ] **Step 2: Run, verify fail:** `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter WorkerLocatorTests` — Expected: FAIL.
- [ ] **Step 3: Implement** (mirror `InstallerLocator`):
```csharp
namespace ClaudeDo.Ui.Services;
public sealed class WorkerLocator
{
private const string InstallJson = "install.json";
private const string WorkerExe = "ClaudeDo.Worker.exe";
private const string WorkerSubdir = "worker";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
{
var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
catch { return null; }
}
}
```
- [ ] **Step 4: Run, verify pass.**
---
## Task 10: App restart-worker + ensure-running
**Files:** Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, `src/ClaudeDo.App/Program.cs`
- [ ] **Step 1:** In `App/Program.cs` register the locator: `sc.AddSingleton<WorkerLocator>();` and ensure `IslandsShellViewModel` receives it (constructor injection; the VM is `AddSingleton<IslandsShellViewModel>()` so DI supplies it).
- [ ] **Step 2:** In `IslandsShellViewModel`, add a `WorkerLocator` constructor dependency and store it. Replace `RestartWorkerService` (the `ServiceController` version) with a process relaunch:
```csharp
private void RestartWorkerService()
{
var exe = _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{
try { p.Kill(entireProcessTree: true); p.WaitForExit(10000); }
catch { /* may have exited */ }
finally { p.Dispose(); }
}
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
}
```
Update `RestartWorkerAsync` messages accordingly (drop the "service not installed" `InvalidOperationException` branch wording → generic failure).
- [ ] **Step 3:** Add ensure-running on startup. After the VM wires up the worker connection, schedule a one-shot check:
```csharp
private bool _ensureRunningAttempted;
private async Task EnsureWorkerRunningAsync()
{
if (_ensureRunningAttempted) return;
_ensureRunningAttempted = true;
await Task.Delay(TimeSpan.FromSeconds(4));
if (_worker.IsConnected) return;
var exe = _workerLocator.Find();
if (exe is null) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* logon task is the primary mechanism; this is a convenience */ }
}
```
Call `_ = EnsureWorkerRunningAsync();` from the VM's existing init path (where the connection is started). Use the actual `WorkerClient` field name and its `IsConnected` member.
- [ ] **Step 4:** Remove `using System.ServiceProcess;` and the `ServiceController` usage. Remove the `System.ServiceProcess.ServiceProcess` package reference from `ClaudeDo.Ui.csproj` if present and now unused.
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` — Expected: succeeds.
- [ ] **Step 6:** Run UI tests: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` — Expected: all pass (incl. `WorkerLocatorTests`). If `IslandsShellViewModel` construction is exercised in a test, supply a `WorkerLocator` instance.
---
## Task 11: Full build + test sweep
- [ ] **Step 1:** Build each project:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
```
Expected: all succeed, 0 errors.
- [ ] **Step 2:** Run all test projects:
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
```
Expected: all pass.
- [ ] **Step 3:** Grep for leftovers: `ServiceController`, `UseWindowsService`, `RegisterServiceStep`, `StopServiceStep`, `StartServiceStep`, `ServiceAccount` in `src/` — Expected: no matches (except the legacy `sc delete ClaudeDoWorker` migration/cleanup strings).
---
## Notes for the implementer
- Worker config property for the log directory: confirm the exact name on `WorkerConfig` (spec assumes `LogRoot`). Use the real one.
- `ProcessRunner.RunAsync` signature is `(string file, string args, string? workingDir, IProgress<string> progress, CancellationToken ct)` returning `(int ExitCode, string Output)` — match existing call sites.
- Keep the legacy `sc delete ClaudeDoWorker` calls (migration + uninstall) so existing service installs are cleaned up.

View File

@@ -0,0 +1,970 @@
# External MCP — UI Parity (Start & Observe) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add MCP tools so an external Claude session can fully *start* and *observe* ClaudeDo sessions (list/config management, run history, logs, agent listing, reset-failed, app-settings read), reaching UI parity for those concerns.
**Architecture:** New focused `[McpServerToolType]` classes in `src/ClaudeDo.Worker/External/`, each injecting an existing worker service (no logic duplication). All registered in the *external* `WebApplication` DI container in `Program.cs`. Mutations broadcast the same SignalR events the hub raises, keeping the UI in sync.
**Tech Stack:** .NET 8, `ModelContextProtocol.Server`, EF Core (SQLite), xUnit integration tests (real SQLite via `DbFixture`).
> **Build/test note (from project memory):** `dotnet build ClaudeDo.slnx` fails on .NET 8. Build the csproj directly:
> `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
> Test: `dotnet test tests/ClaudeDo.Worker.Tests`
---
## File Structure
**Create:**
- `src/ClaudeDo.Worker/External/ListMcpTools.cs` — list create/update/delete tools
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` — list-config + task-config tools + DTO
- `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs` — run history + log read tools + DTO
- `src/ClaudeDo.Worker/External/AgentMcpTools.cs` — agent listing tool
- `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs` — reset-failed-task tool
- `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs` — app-settings read tool
- `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
**Modify:**
- `src/ClaudeDo.Worker/Program.cs:188-217` — register new tool classes + services in the external builder
- `src/ClaudeDo.Worker/CLAUDE.md:27` — remove stale tag tools, refresh the External MCP tool inventory
**Reference (existing, do not change):**
- `ListRepository``AddAsync`, `UpdateAsync`, `DeleteAsync`, `GetByIdAsync`, `GetAllAsync`, `GetConfigAsync`, `SetConfigAsync`, `DeleteConfigAsync`
- `TaskRepository.UpdateAgentSettingsAsync(taskId, model?, systemPrompt?, agentPath?)`
- `TaskRunRepository``GetByTaskIdAsync`, `GetByIdAsync`, `GetLatestByTaskIdAsync`
- `TaskResetService.ResetAsync(taskId, ct)` — refuses Running, discards worktree, resets to Idle
- `AgentFileService.ScanAsync(ct)``List<AgentInfo>`; `AgentInfo(string Name, string Description, string Path)`
- `AppSettingsRepository.GetAsync()``AppSettingsEntity`
- `TaskRunEntity` fields: `Id, TaskId, RunNumber, SessionId, IsRetry, ResultMarkdown, StructuredOutputJson, ErrorMarkdown, ExitCode, TurnCount, TokensIn, TokensOut, LogPath, StartedAt, FinishedAt`
- `CommitTypeRegistry.DefaultType`
- `HubBroadcaster.ListUpdated(id)`, `.TaskUpdated(id)`
> **Spec refinement (YAGNI):** the spec listed an agent "refresh" tool. `AgentFileService.ScanAsync` reads disk fresh on every call, so a separate refresh is redundant for an MCP client. We implement `ListAgents` only.
---
## Task 1: List management tools (`ListMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/ListMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
namespace ClaudeDo.Worker.Tests.External;
internal sealed class ListToolsHubClients : IHubClients
{
public ListToolsClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> e) => Proxy;
public IClientProxy Client(string c) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
public IClientProxy Group(string g) => Proxy;
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
public IClientProxy User(string u) => Proxy;
public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
}
internal sealed class ListToolsClientProxy : IClientProxy
{
public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask;
}
internal sealed class ListToolsHubContext : IHubContext<WorkerHub>
{
public ListToolsHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class ListMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly ListMcpTools _sut;
public ListMcpToolsTests()
{
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_sut = new ListMcpTools(_lists, new HubBroadcaster(new ListToolsHubContext()));
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
[Fact]
public async Task CreateList_PersistsWithDefaults()
{
var dto = await _sut.CreateList("My List", null, null, CancellationToken.None);
Assert.Equal("My List", dto.Name);
var loaded = await _lists.GetByIdAsync(dto.Id);
Assert.NotNull(loaded);
Assert.Equal("chore", loaded!.DefaultCommitType);
}
[Fact]
public async Task UpdateList_PatchesNameWorkingDirAndCommitType()
{
var created = await _sut.CreateList("orig", null, null, CancellationToken.None);
var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None);
Assert.Equal("renamed", dto.Name);
Assert.Equal("C:/work", dto.WorkingDir);
var loaded = await _lists.GetByIdAsync(created.Id);
Assert.Equal("feat", loaded!.DefaultCommitType);
}
[Fact]
public async Task UpdateList_NotFound_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.UpdateList("missing", "x", null, null, CancellationToken.None));
}
[Fact]
public async Task DeleteList_RemovesList()
{
var created = await _sut.CreateList("gone", null, null, CancellationToken.None);
await _sut.DeleteList(created.Id, CancellationToken.None);
Assert.Null(await _lists.GetByIdAsync(created.Id));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
Expected: FAIL — `ListMcpTools` does not exist (compile error).
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
[McpServerToolType]
public sealed class ListMcpTools
{
private readonly ListRepository _lists;
private readonly HubBroadcaster _broadcaster;
public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster)
{
_lists = lists;
_broadcaster = broadcaster;
}
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
public async Task<ListSummaryDto> CreateList(
string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidOperationException("name is required.");
var entity = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = name,
WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir,
DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType,
CreatedAt = DateTime.UtcNow,
};
await _lists.AddAsync(entity, cancellationToken);
await _broadcaster.ListUpdated(entity.Id);
return ToDto(entity);
}
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
public async Task<ListSummaryDto> UpdateList(
string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
{
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
if (name is not null) entity.Name = name;
if (workingDir is not null)
entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir;
if (commitType is not null)
entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType;
await _lists.UpdateAsync(entity, cancellationToken);
await _broadcaster.ListUpdated(listId);
return ToDto(entity);
}
[McpServerTool, Description("Delete a list and its tasks. Irreversible.")]
public async Task DeleteList(string listId, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
await _lists.DeleteAsync(listId, cancellationToken);
await _broadcaster.ListUpdated(listId);
}
private static ListSummaryDto ToDto(ListEntity l) =>
new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType);
}
```
> If `CommitTypeRegistry` is not in scope, add `using ClaudeDo.Data;` (verify its namespace with a quick grep before assuming).
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ListMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
git commit -m "feat(worker): add external MCP list-management tools"
```
---
## Task 2: List & task config tools (`ConfigMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.External;
public sealed class ConfigMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly ConfigMcpTools _sut;
public ConfigMcpToolsTests()
{
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_tasks = new TaskRepository(_ctx);
_sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new ListToolsHubContext()));
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task<string> SeedListAsync()
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
return id;
}
[Fact]
public async Task SetAndGetListConfig_RoundTrips()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None);
var cfg = await _sut.GetListConfig(listId, CancellationToken.None);
Assert.NotNull(cfg);
Assert.Equal("sonnet", cfg!.Model);
Assert.Equal("be terse", cfg.SystemPrompt);
Assert.Null(cfg.AgentPath);
}
[Fact]
public async Task SetListConfig_AllNull_ClearsConfig()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None);
await _sut.SetListConfig(listId, null, null, null, CancellationToken.None);
Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None));
}
[Fact]
public async Task SetTaskConfig_PersistsOverrides()
{
var listId = await SeedListAsync();
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "t",
Status = ClaudeDo.Data.Models.TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(task);
await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal("opus", loaded!.Model);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
Expected: FAIL — `ConfigMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
[McpServerToolType]
public sealed class ConfigMcpTools
{
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster;
public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
{
_lists = lists;
_tasks = tasks;
_broadcaster = broadcaster;
}
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")]
public async Task<TaskConfigDto?> GetListConfig(string listId, CancellationToken cancellationToken)
{
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath);
}
[McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")]
public async Task SetListConfig(
string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var m = Nullify(model);
var sp = Nullify(systemPrompt);
var ap = Nullify(agentPath);
if (m is null && sp is null && ap is null)
await _lists.DeleteConfigAsync(listId, cancellationToken);
else
await _lists.SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap,
}, cancellationToken);
await _broadcaster.ListUpdated(listId);
}
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null to clear a field.")]
public async Task SetTaskConfig(
string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}
```
> Verify `UpdateAgentSettingsAsync` accepts a `CancellationToken` (read `TaskRepository.cs:157`). If it does not, drop the `cancellationToken` argument from that call.
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
git commit -m "feat(worker): add external MCP list/task config tools"
```
---
## Task 3: Run history & log tools (`RunHistoryMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.External;
public sealed class RunHistoryMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRunRepository _runs;
private readonly RunHistoryMcpTools _sut;
public RunHistoryMcpToolsTests()
{
_ctx = _db.CreateContext();
_runs = new TaskRunRepository(_ctx);
_sut = new RunHistoryMcpTools(_runs);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task SeedTaskAsync(string taskId)
{
var lists = new ListRepository(_ctx);
var tasks = new TaskRepository(_ctx);
var listId = Guid.NewGuid().ToString();
await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
await tasks.AddAsync(new TaskEntity
{
Id = taskId, ListId = listId, Title = "t",
Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore",
});
}
[Fact]
public async Task ListRuns_ReturnsProjectedRuns()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20,
});
var list = await _sut.ListRuns(taskId, CancellationToken.None);
Assert.Single(list);
Assert.Equal("done", list[0].ResultMarkdown);
Assert.Equal(10, list[0].TokensIn);
}
[Fact]
public async Task GetTaskLog_NoLog_Throws()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.GetTaskLog(taskId, CancellationToken.None));
}
[Fact]
public async Task GetTaskLog_ReadsLatestRunLogFile()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
await File.WriteAllTextAsync(logPath, "hello log");
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath,
});
var content = await _sut.GetTaskLog(taskId, CancellationToken.None);
Assert.Equal("hello log", content);
File.Delete(logPath);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
Expected: FAIL — `RunHistoryMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record RunDto(
string Id, int RunNumber, string? SessionId, bool IsRetry,
string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown,
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
DateTime? StartedAt, DateTime? FinishedAt);
[McpServerToolType]
public sealed class RunHistoryMcpTools
{
private readonly TaskRunRepository _runs;
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
[McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")]
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
{
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
return runs.Select(ToDto).ToList();
}
[McpServerTool, Description("Get a single execution run by its run id.")]
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
{
var run = await _runs.GetByIdAsync(runId, cancellationToken)
?? throw new InvalidOperationException($"Run {runId} not found.");
return ToDto(run);
}
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
{
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
throw new InvalidOperationException("No log available for the latest run.");
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
}
private static RunDto ToDto(TaskRunEntity r) => new(
r.Id, r.RunNumber, r.SessionId, r.IsRetry,
r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown,
r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut,
r.StartedAt, r.FinishedAt);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
git commit -m "feat(worker): add external MCP run-history and log tools"
```
---
## Task 4: Agent listing tool (`AgentMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/AgentMcpTools.cs`
- Test: none new — covered indirectly; `AgentFileService` already has unit coverage. (This tool is a thin pass-through.)
- [ ] **Step 1: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Agents;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class AgentMcpTools
{
private readonly AgentFileService _agents;
public AgentMcpTools(AgentFileService agents) => _agents = agents;
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
=> await _agents.ScanAsync(cancellationToken);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/External/AgentMcpTools.cs
git commit -m "feat(worker): add external MCP agent-listing tool"
```
---
## Task 5: Reset-failed-task tool (`LifecycleMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
`TaskResetService.ResetAsync` already refuses Running tasks and discards the worktree. The MCP tool adds a guard that the task must be `Failed` (the only sensible reset target via this surface) and delegates.
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Config;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
public sealed class LifecycleMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public LifecycleMcpToolsTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private LifecycleMcpTools BuildSut()
{
var cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
};
var dbFactory = _db.CreateFactory();
var broadcaster = new HubBroadcaster(new ListToolsHubContext());
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
return new LifecycleMcpTools(_tasks, reset);
}
private async Task<TaskEntity> SeedTaskAsync(TaskStatus status)
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task ResetFailedTask_OnFailed_ResetsToIdle()
{
var task = await SeedTaskAsync(TaskStatus.Failed);
var sut = BuildSut();
await sut.ResetFailedTask(task.Id, CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Idle, loaded!.Status);
}
[Fact]
public async Task ResetFailedTask_OnNonFailed_Throws()
{
var task = await SeedTaskAsync(TaskStatus.Done);
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ResetFailedTask(task.Id, CancellationToken.None));
}
[Fact]
public async Task ResetFailedTask_NotFound_Throws()
{
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ResetFailedTask("missing", CancellationToken.None));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
Expected: FAIL — `LifecycleMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class LifecycleMcpTools
{
private readonly TaskRepository _tasks;
private readonly TaskResetService _reset;
public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
{
_tasks = tasks;
_reset = reset;
}
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")]
public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Failed)
throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
await _reset.ResetAsync(taskId, cancellationToken);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
Expected: PASS (3 tests). (Git-dependent worktree discard is skipped when no worktree row exists — these tasks have none.)
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/LifecycleMcpTools.cs tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
git commit -m "feat(worker): add external MCP reset-failed-task tool"
```
---
## Task 6: App-settings read tool (`AppSettingsMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs`
- Test: none new — thin read-only pass-through over `AppSettingsRepository.GetAsync`.
This tool is read-only by design (writing app settings is out of scope). It uses the db factory (registered as a singleton in the external builder) to open a context per call, mirroring the hub's pattern.
- [ ] **Step 1: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record AppSettingsReadDto(
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
string WorktreeStrategy, string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
[McpServerToolType]
public sealed class AppSettingsMcpTools
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
{
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var row = await new AppSettingsRepository(ctx).GetAsync();
return new AppSettingsReadDto(
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
row.WorktreeStrategy, row.CentralWorktreeRoot,
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
}
}
```
> Verify `AppSettingsRepository.GetAsync` signature (it may take a `CancellationToken`). Adjust the call if so. Confirm `AppSettingsEntity` property names match (`DefaultModel`, `DefaultMaxTurns`, `DefaultPermissionMode`, `WorktreeStrategy`, `CentralWorktreeRoot`, `WorktreeAutoCleanupEnabled`, `WorktreeAutoCleanupDays`) — they are used identically in `WorkerHub.GetAppSettings` (lines 206-219).
- [ ] **Step 2: Verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
git commit -m "feat(worker): add external MCP app-settings read tool"
```
---
## Task 7: Register new tools in the external MCP app
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs:188-217`
The external `WebApplication` has its own DI container. Each new tool class and every service it needs must be registered there, and each class added via `.WithTools<T>()`.
- [ ] **Step 1: Add service + tool registrations**
In the `if (cfg.ExternalMcpPort > 0)` block, after the existing
`externalBuilder.Services.AddScoped<ExternalMcpService>();` line, add:
```csharp
externalBuilder.Services.AddScoped<TaskRunRepository>();
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
externalBuilder.Services.AddScoped<TaskResetService>();
externalBuilder.Services.AddScoped<ListMcpTools>();
externalBuilder.Services.AddScoped<ConfigMcpTools>();
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
```
And extend the `AddMcpServer()` chain:
```csharp
externalBuilder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<ExternalMcpService>()
.WithTools<ListMcpTools>()
.WithTools<ConfigMcpTools>()
.WithTools<RunHistoryMcpTools>()
.WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>()
.WithTools<AppSettingsMcpTools>();
```
> **Verify before editing:** confirm `WorktreeManager` and `AgentFileService` are registered as singletons in the *main* `app` container (grep `Program.cs` for `WorktreeManager` and `AgentFileService`). If `AgentFileService` is constructed with a directory string rather than DI-resolved, register it in the external builder the same way the main app does (e.g. `new AgentFileService(agentsDir)`), not via `GetRequiredService`. `TaskResetService` depends on `WorktreeManager`, `IDbContextFactory`, `HubBroadcaster`, `ITaskStateService`, `ILogger<TaskResetService>` — all already singletons in the external builder except `WorktreeManager` (added above) and the logger (provided by default logging).
- [ ] **Step 2: Build the worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, no DI-related compile errors.
- [ ] **Step 3: Run the full worker test suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: PASS (all existing + new tests).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): register new external MCP tool classes"
```
---
## Task 8: Documentation cleanup
**Files:**
- Modify: `src/ClaudeDo.Worker/CLAUDE.md:27`
- [ ] **Step 1: Replace the stale External MCP inventory line**
Replace the line beginning `- **External/ExternalMcpService** — always-on MCP tools…` with an accurate inventory that drops the (non-existent) tag tools and lists the new surface:
```markdown
- **External/*** — always-on MCP tools for general Claude sessions, organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle`/`Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask`
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog`
- `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only)
- Purpose is scoped to *starting* and *observing* sessions — no worktree/merge, multi-turn, planning, or app-settings writes. Auth via optional `X-ClaudeDo-Key` header.
```
- [ ] **Step 2: Commit**
```bash
git add src/ClaudeDo.Worker/CLAUDE.md
git commit -m "docs(worker): correct external MCP tool inventory, drop removed tags"
```
---
## Self-Review
**Spec coverage:**
- List management → Task 1 ✓
- List & task config → Task 2 ✓
- Run history & logs → Task 3 ✓
- Agents (read-only) → Task 4 ✓
- Reset failed task → Task 5 ✓
- App settings (read-only) → Task 6 ✓
- DI wiring (separate external app) → Task 7 ✓
- Tag doc cleanup → Task 8 ✓
- Out-of-scope items (multi-turn, worktree ops, planning, app-settings writes, tags, agent create/edit) → not implemented ✓
**Placeholder scan:** No TBD/TODO. The three "verify before editing" notes point at real signatures the implementer must confirm (cancellation-token overloads, `AgentFileService` construction, registry namespaces) — these are verification steps with concrete fallbacks, not placeholders.
**Type consistency:** `ListSummaryDto`, `TaskConfigDto`, `RunDto`, `AppSettingsReadDto` defined once and used consistently. `AgentInfo` reused directly (no new DTO). Tool method names match between implementation, tests, and the Task-8 doc inventory (`CreateList`/`UpdateList`/`DeleteList`, `GetListConfig`/`SetListConfig`/`SetTaskConfig`, `ListRuns`/`GetRun`/`GetTaskLog`, `ListAgents`, `ResetFailedTask`, `GetAppSettings`).

View File

@@ -0,0 +1,118 @@
# Planning: Draft → Planned → Queue gate
**Date:** 2026-05-29
**Status:** Approved (design)
## Problem
When a planning parent is finalized, `PlanningChainCoordinator.SetupChainAsync` immediately
enqueues the entire child chain (child[0] runs, successors wait blocked on their predecessor).
There is no review step: a user cannot hold finalized subtasks in a "ready but not running"
state, and the "DRAFT" label in the UI is only a derived side effect
(`TaskRowViewModel.IsDraft => IsChild && Status == Idle`) with no gate behind it — a draft
child already satisfies `CanSendToQueue` and can be queued directly.
We want an explicit lifecycle for planning children:
- **Draft** — child of a plan still being built (parent `PlanningPhase == Active`). Not queueable.
- **Planned** — child of a finalized plan (parent `PlanningPhase == Finalized`), still `Idle`. Queueable.
Finalizing a plan promotes its children Draft → Planned **without** queuing anything. The user
then explicitly sends the plan to the queue, which builds the sequential chain (today's behavior,
just user-triggered). The gate is enforced in both the UI and the server so no path (UI, MCP,
external agents) can queue or run a Draft child.
## Decisions
- **Q1 — Finalize semantics:** Finalize auto-marks children **Planned** (not Draft); nothing is
queued until the user explicitly sends to queue. Draft exists only while the plan is unfinalized.
- **Q2 — Queue granularity:** A single **parent-level** "Send plan to queue" action queues all
Planned children as a sequential chain (reuses `SetupChainAsync`). No per-child queueing.
- **Q3 — Enforcement:** UI **and** server. The gate is a server invariant in `TaskStateService`,
so MCP / external agents are bound by it too.
- **Data model — Approach 1 (derive, no schema change):** Draft/Planned is a pure function of the
parent's `PlanningPhase`. No new column, no migration, no parent/child drift.
## Core invariant
No schema change. A child task's stage is derived from its parent's `PlanningPhase`:
| Parent `PlanningPhase` | Child (`Status = Idle`) | Queueable? |
|---|---|---|
| `Active` (plan being built) | **DRAFT** | no |
| `Finalized` | **PLANNED** | yes |
**Server invariant:** a child task (`ParentTaskId != null`) may transition `Idle → Queued` or
`Idle → Running` **only if** its parent's `PlanningPhase == Finalized`. Standalone (non-child)
tasks are unaffected.
A failed/cancelled child returning to `Idle` while its parent is still `Finalized` is therefore
"Planned" again and re-queueable — desired.
## Components
### Worker / server
1. **`TaskStateService` transition guard** — the single enforcement point. When a child task is
about to enter `Queued` or `Running`, look up the parent's `PlanningPhase`; if it is not
`Finalized`, return a failed `TransitionResult` (no exception — consistent with the existing
no-throw transition pattern). This covers:
- UI single-task enqueue (`SetTaskStatus → Queued`)
- `RunNow` (`StartRunningAsync`, `Idle → Running`)
- the queue picker's `Queued → Running` claim (defense in depth; a Draft child can't reach
`Queued` in the first place)
- MCP `UpdateTaskStatus(Queued)` / `RunTaskNow`
2. **Finalize stops auto-queuing** — audit every `FinalizeAsync(taskId, queueAgentTasks, ct)`
call site and pass `queueAgentTasks: false`. Known callers to update: the UI finalize command
and the planning-MCP finalize tool. After this, `FinalizeAsync` only flips the parent to
`Finalized` (children become Planned); `SetupChainAsync` is no longer invoked from finalize.
3. **New queue action** — add `WorkerHub.QueuePlan(parentTaskId)`
`PlanningChainCoordinator.SetupChainAsync(parentTaskId)`. Guarded so it only runs when the
parent is `Finalized`; otherwise returns a failure the UI surfaces. This is the user-triggered
replacement for the auto-chain.
### UI
4. **`TaskRowViewModel`**
- Add `ParentFinalized` (`bool`), set by `TasksIslandViewModel`.
- `IsDraft => IsChild && Status == Idle && !ParentFinalized`
- `IsPlanned => IsChild && Status == Idle && ParentFinalized`
- `CanSendToQueue` gains `&& (!IsChild || ParentFinalized)`
- Child badge renders `DRAFT` / `PLANNED` (drive off `IsDraft` / `IsPlanned`).
- Raise `PropertyChanged` for the new derived members from the relevant `On*Changed` hooks
(`OnStatusChanged`, `OnParentTaskIdChanged`, and a new `OnParentFinalizedChanged`).
5. **`TasksIslandViewModel`** — when building/refreshing rows, resolve each child's parent
`PlanningPhase` from the loaded task set and set `ParentFinalized`. If the parent is not in the
loaded set, default to `false` (Draft — the safe, non-queueable default).
6. **`DetailsIslandViewModel`**
- `CanEnqueue` for a selected child additionally requires the parent to be `Finalized`.
- Add a parent-level **"Send plan to queue"** command, enabled when the selected task is a
`Finalized` planning parent with at least one Planned (`Idle`) child and nothing already
queued/running; calls `QueuePlanAsync(parentId)`.
7. **`IWorkerClient` / `WorkerClient`** — add `QueuePlanAsync(string parentId)`. Update the test
fakes (UI + Worker test projects) to implement the new member.
## Testing
- **Worker (`TaskStateService`):** child enqueue/run rejected when parent `Active`; allowed when
parent `Finalized`. Standalone task enqueue still allowed. Picker skips/ignores draft children.
- **Worker (finalize):** `FinalizeAsync(..., queueAgentTasks: false)` flips parent to `Finalized`
and queues nothing; children remain `Idle`.
- **Worker (`QueuePlan`):** on a `Finalized` parent, builds the sequential chain (child[0]
unblocked + queued, successors blocked on predecessor); on a non-`Finalized` parent, fails.
- **UI VM (`TaskRowViewModel`):** Draft vs Planned derivation and `CanSendToQueue` gating across
parent phases; badge text.
- **UI VM (`DetailsIslandViewModel`):** `CanEnqueue` gating for children; "Send plan to queue"
enablement.
## Out of scope
- Per-child manual promotion while a plan is still being built (Draft → Planned without
finalizing). Promotion happens only via finalize.
- Per-child independent queueing (Q2 = parent-level chain only).
- Any database schema / migration change.

View File

@@ -0,0 +1,138 @@
# Repo Import List Helper — Design
**Date:** 2026-05-29
**Status:** Approved (pending spec review)
## Problem
Creating lists is one-at-a-time: click `+ New list`, then open List Settings to set the
working directory. Users with many repos under a few parent folders want to wire them all up
in one pass.
## Goal
A "list helper" that scans one or more parent folders for git repos, presents them as a
checklist, and bulk-creates a list (with `WorkingDir` pre-filled) for each ticked repo.
## Entry Points
1. **Help menu** — the title-bar dropdown in `MainWindow.axaml` that contains `About…`,
`Worktrees…`, etc. Add a new `MenuItem` `Add repos as lists…` wired to a command on
`MainWindowViewModel`.
2. **Lists island** — a small folder icon button beside the existing `+ New list` button in
`ListsIslandView.axaml`, wired to a command on `ListsIslandViewModel`.
Both open the same modal.
## Components
### `RepoScanner` (new, `ClaudeDo.Ui/Services` or `ClaudeDo.Data`)
Pure filesystem helper, no git library. Given a parent folder path, enumerates immediate
subdirectories and returns those that contain a `.git` entry (directory or file). Kept
separate from the VM so it is unit-testable.
```
IReadOnlyList<RepoCandidate> Scan(string parentFolder)
record RepoCandidate(string Name, string FullPath)
```
- Skips the parent itself; only immediate children are considered (non-recursive).
- `.git` may be a directory (normal repo) or a file (worktree/submodule) — both count.
- Returns empty on missing/unreadable folder rather than throwing.
### `RepoImportModalViewModel` (new, `ClaudeDo.Ui/ViewModels/Modals`)
Follows the existing modal-VM pattern (`CloseAction`, resolved from DI).
Dependencies:
- `IDbContextFactory<ClaudeDoDbContext>` — load existing lists' `WorkingDir` values (for the
"already added" check) and create new `ListEntity` rows. Same dependency
`ListsIslandViewModel` already uses.
State:
- `ObservableCollection<RepoImportItemViewModel> Repos` — the combined checklist.
- A set of parent folder paths already scanned (to de-dupe re-adds).
- `CreateCount` — computed count of ticked-and-new rows (drives the confirm button label).
Commands:
- `AddFolderAsync` — invokes the folder picker (via view code-behind callback, see below),
scans each chosen folder with `RepoScanner`, appends new candidates. De-dupes by full path
(case-insensitive) against rows already present.
- `CreateAsync` — for each ticked, non-existing row, create a `ListEntity` via
`ListRepository.AddAsync` (Name = folder name, WorkingDir = full path,
DefaultCommitType = `CommitTypeRegistry.DefaultType`, fresh `Guid` id, `CreatedAt` = now).
Then `CloseAction()`.
- `Cancel``CloseAction()`.
On load, fetch all existing lists once and capture their `WorkingDir`s into a case-insensitive
set; each appended candidate whose path is in that set is marked `AlreadyAdded`.
### `RepoImportItemViewModel` (new)
- `Name`, `FullPath` (display).
- `AlreadyAdded` (bool) — true if a list already points at this path.
- `IsChecked` ([ObservableProperty]) — defaults `true` for new repos. For already-added rows it
is forced `true` and the checkbox is disabled.
- `CanToggle` => `!AlreadyAdded` (binds to checkbox `IsEnabled`).
### `RepoImportModalView` (new, `ClaudeDo.Ui/Views/Modals`)
A `Window` styled like the other modals (header bar, body, footer), shown via
`ShowDialog(owner)`.
- **Header:** title `ADD REPOS AS LISTS` + close button.
- **Top of body:** `Add folder…` button.
- **Body:** scrollable `ItemsControl` over `Repos`. Each row = `CheckBox` (IsChecked two-way,
IsEnabled = `CanToggle`) + repo name + dim full path + `(already added)` label when
`AlreadyAdded`.
- **Footer:** `Create {CreateCount} lists` button (disabled when `CreateCount == 0`) + `Cancel`.
- Folder picker lives in the code-behind (mirrors `ListSettingsModalView.BrowseClicked`):
`OpenFolderPickerAsync` with `AllowMultiple = true`, results handed to the VM's
`AddFolderAsync`.
## Data Flow
1. User opens the modal from either entry point → modal loads existing lists' `WorkingDir`s.
2. User clicks `Add folder…` → picks one or more parent folders → `RepoScanner` finds repos →
rows appended (de-duped), already-added rows shown ticked+disabled.
3. User adjusts ticks → clicks `Create N lists`.
4. VM creates one `ListEntity` per ticked-new row via `ListRepository`.
5. Modal closes → the **caller reloads the Lists island** so new lists appear:
- Lists-island entry point: `ListsIslandViewModel.LoadAsync()`.
- Help-menu entry point: `MainWindowViewModel` reloads its `Lists` (the
`ListsIslandViewModel` instance) after the modal closes.
## DI / Wiring
- Register `RepoImportModalViewModel` (transient) alongside other modal VMs.
- Register `RepoScanner` if implemented as an injected service; a static helper needs no
registration.
- `ListsIslandViewModel` gains `Func<RepoImportModalViewModel, Task>? ShowRepoImportModal` and
an `OpenRepoImportCommand`, wired in `ListsIslandView.axaml.cs` (mirrors
`ShowListSettingsModal`).
- `MainWindowViewModel` gains the same `Func` + an `OpenRepoImportCommand`, wired in
`MainWindow.axaml.cs`.
## Error Handling
- Unreadable / missing folders: `RepoScanner` returns empty, no crash.
- Re-adding a folder already scanned: de-duped by path, no duplicate rows.
- Two ticked repos sharing a folder name: both created (list names are not unique) — acceptable.
- List creation failure (rare): best-effort per the existing pattern; do not block remaining
creations.
## Testing
- `RepoScanner` unit tests (the testable seam): a temp directory tree with a mix of git repos
(`.git` dir), a `.git`-file repo, plain folders, and an empty/missing parent. Assert only the
repo subfolders are returned and missing folders yield empty.
- VM-level "already added" logic and `CreateCount` can be exercised if a test seam is convenient,
but the filesystem scanner is the primary unit under test. UI wiring verified manually.
## Out of Scope (YAGNI)
- Recursive / deep scanning.
- Inline editing of the list name before creation.
- Setting model / system prompt / agent during import (tuned later per-list in List Settings).
- Picking repo folders directly (only parent-folder scan, per decision).

View File

@@ -0,0 +1,165 @@
# Worker per-user autostart (drop Windows service)
Status: approved 2026-05-29
Author: brainstorm session (mika kuns + Claude)
## Problem
The worker runs as a Windows **service** registered under `LocalSystem`. The worker
shells out to the `claude` CLI, whose authentication is stored per-user
(`%USERPROFILE%\.claude`). Under `LocalSystem` the worker uses the system profile and
cannot see the user's Claude login, so task execution fails. The installer even exposes a
"Current User" service-account radio that the backend rejects (`RegisterServiceStep`
fails the install). Net effect: the only installable configuration cannot authenticate
Claude.
## Goal
Run the worker as the logged-in **user** so it inherits the user's Claude auth, starting
automatically at logon and staying alive in the background (independent of the desktop
app, so Prime/scheduled tasks fire when the UI is closed).
## Decisions (locked)
1. **Lifetime:** background from logon, always — independent of the UI.
2. **Mechanism:** per-user **logon Scheduled Task** (`schtasks`), run only when the user is
logged on (no stored password), hidden, with restart-on-failure.
3. **No console window:** worker becomes `WinExe`; add a **Serilog rolling file sink** so
worker diagnostics aren't lost.
4. **App ensures running:** "Restart Worker" becomes process-based; on app startup, if
SignalR doesn't connect within a few seconds, the app launches the worker.
5. **Auto-migrate:** the installer detects and removes the old `ClaudeDoWorker` service,
then registers the task. Uninstall removes the task + kills the worker process.
## Non-goals
- Cross-account elevation (admin elevates as a *different* account than the interactive
user). Single-user / user-is-admin is assumed; the task targets the interactive user.
- Running the worker when no user is logged on (that's the whole point — it must be a user
session for Claude auth).
---
## Component changes
### ClaudeDo.Worker
- **`ClaudeDo.Worker.csproj`**: `<OutputType>WinExe</OutputType>`. Add packages
`Serilog.AspNetCore` and `Serilog.Sinks.File`.
- **`Program.cs`**:
- Remove `builder.Host.UseWindowsService(...)`.
- Configure Serilog file sink: path `<LogRoot>/worker-.log`, `rollingInterval: Day`,
`retainedFileCountLimit: 7`, shared write. `LogRoot` comes from `WorkerConfig`
(expand `~`). Wire via `builder.Host.UseSerilog(...)`.
- **Single-instance guard:** at startup create `new Mutex(true, @"Local\ClaudeDoWorker",
out var createdNew)`. If `!createdNew`, log "another worker instance is already
running" and exit 0. Hold the mutex for process lifetime. `Local\` namespace = per
user session, which is what we want.
- CLI preflight (`ClaudeCliPreflight`) behavior unchanged.
### ClaudeDo.Installer
- **New `Steps/RegisterAutostartStep.cs`** (`IInstallStep`, "Register Autostart"):
- Build a Task Scheduler **definition XML** (UTF-16) and register via
`schtasks /Create /TN "ClaudeDoWorker" /XML "<tmpfile>" /F`.
- XML shape:
- `Principals/Principal`: `UserId` = current interactive user
(`WindowsIdentity.GetCurrent().Name`), `LogonType=InteractiveToken`,
`RunLevel=LeastPrivilege`.
- `Triggers/LogonTrigger` with the same `UserId`.
- `Settings`: `Hidden=true`, `MultipleInstancesPolicy=IgnoreNew`,
`StartWhenAvailable=true`, `ExecutionTimeLimit=PT0S`,
`DisallowStartIfOnBatteries=false`, `StopIfGoingOnBatteries=false`,
`RestartOnFailure` with `Interval` (>= `PT1M`; Task Scheduler's minimum granularity
is one minute) and `Count=3`.
- `Actions/Exec/Command`: quoted path to `<installDir>/worker/ClaudeDo.Worker.exe`.
- The XML builder is a **pure function** (string in → XML string out) so it is unit
testable without admin.
- **`MigrateServiceStep`** (or folded into `RegisterAutostartStep` as a first phase):
detect the old service via `sc query ClaudeDoWorker`; if present, `sc stop` then
`sc delete` (poll for clearance like the old `RegisterServiceStep` did). No-op when the
service doesn't exist (fresh installs).
- **Rename `StopServiceStep``StopWorkerStep`, `StartServiceStep``StartWorkerStep`**,
reworked to be process/task based:
- Stop: `schtasks /End /TN ClaudeDoWorker` (ignore errors) + kill any
`ClaudeDo.Worker` process whose `MainModule.FileName` is under the install dir;
wait for exit. This unlocks `worker/` binaries before extract.
- Start: `schtasks /Run /TN ClaudeDoWorker` (preferred — launches as the task principal).
Used by fresh install (so the worker runs immediately rather than waiting for next
logon) and by Settings "restart".
- **`Pages/ServicePage/ServicePageViewModel.cs`**: remove `IsLocalSystem`/`IsCurrentUser`
radios and `ServiceAccount` usage. Keep SignalR port, Claude CLI path, "Start at logon"
toggle (`AutoStart`), restart delay (maps to task `RestartOnFailure/Interval`, clamped
to >= 1 min). Update `ServicePageView.xaml` accordingly. Remove `ServiceAccount` from
`InstallContext`.
- **`RegisterServiceStep.cs`**: deleted (replaced by `RegisterAutostartStep`).
- **Pipelines (`InstallPageViewModel`)**:
- Fresh: DownloadAndExtract → WriteConfig → InitDatabase → **RegisterAutostart** (incl.
migration no-op) → CreateShortcuts → WriteUninstallRegistry → WriteInstallManifest →
**StartWorker**.
- Update: **StopWorker** → DownloadAndExtract → **RegisterAutostart** (migrates old
service) → **StartWorker** → WriteInstallManifest → WriteUninstallRegistry.
- **DI (`App.xaml.cs`)**: register the renamed/new steps (concrete + `IInstallStep` where
needed, following the existing double-registration pattern).
- **`Core/UninstallRunner.cs`**: replace `sc delete ClaudeDoWorker` with
`schtasks /Delete /TN ClaudeDoWorker /F` and kill the worker process; also `sc delete`
the legacy service best-effort (in case an old service still lingers).
### ClaudeDo.Ui / ClaudeDo.App
- **New `Services/WorkerLocator.cs`**: resolve `<installDir>/worker/ClaudeDo.Worker.exe`
by walking up for `install.json` then registry `InstallLocation` (mirrors
`InstallerLocator`).
- **`ViewModels/IslandsShellViewModel.cs`**:
- `RestartWorkerService`: drop `System.ServiceProcess.ServiceController`. Kill worker
process(es) under the install dir, then `Process.Start(workerExe)`.
- **Ensure-running:** on startup, if the `WorkerClient` connection isn't established
within ~4s, launch the worker via `WorkerLocator` + `Process.Start`. Guarded so it
runs at most once per app session.
- Remove the `System.ServiceProcess` package reference / usings if no longer used.
---
## Data flow
- **Logon:** Task Scheduler starts `ClaudeDo.Worker.exe` in the user session → mutex
acquired → Serilog file logging → SignalR hub on `127.0.0.1:47821` → app connects.
- **App start with worker down:** app waits ~4s for SignalR; if absent, `Process.Start`
worker → mutex acquired → hub up → app connects.
- **Duplicate launch (task + app race):** second instance fails the mutex → logs → exits 0.
- **Restart Worker button:** kill worker proc → relaunch → mutex reacquired.
## Error handling
- `schtasks`/`sc` calls go through the existing `ProcessRunner`; non-zero exits surface as
`StepResult.Fail` with the captured output (except best-effort cleanup which is ignored).
- Worker single-instance: losing the mutex is a normal, non-error exit (code 0).
- App ensure-running: `Process.Start` failures are swallowed (the logon task is the primary
mechanism; the app launch is a convenience).
## Testing
- **Unit (no admin required):**
- Task-definition XML builder: asserts UserId, LogonType, Hidden, RestartOnFailure
interval clamping, quoted command path.
- `WorkerLocator`: path resolution via temp `install.json`.
- Migration decision: given `sc query` output (exists / not-found), decide stop+delete vs
no-op — keep the decision pure, mock `ProcessRunner` output.
- Restart-delay → task interval clamping (`< 1 min``PT1M`).
- **Manual verification (post-build, on this machine):**
1. Update from installed `1.0.2-alpha`: old service is removed (`sc query ClaudeDoWorker`
→ not found), task exists (`schtasks /Query /TN ClaudeDoWorker`), worker process runs
as the user, app connects, no console window.
2. Worker log file appears at `~/.todo-app/logs/worker-<date>.log`.
3. Kill worker → click Restart Worker in app → reconnects.
4. Close app, confirm worker still running (Prime/queue alive); reopen app → connects.
5. Log off / log on → worker autostarts.
6. Uninstall → task gone, worker process gone, (data kept unless opted out).
## Risks
- **Task restart granularity is minutes**, not the old seconds-level service restart. The
worker's own long-running resilience + the app ensure-running cover short gaps; acceptable.
- **Elevated installer must target the interactive user.** Using
`WindowsIdentity.GetCurrent().Name` is correct when the user elevates themselves (the
assumed single-user case). Documented non-goal otherwise.

View File

@@ -0,0 +1,125 @@
# External MCP — UI Parity for Start & Observe
**Date:** 2026-05-30
**Status:** Approved (design)
## Goal
Expand the always-on **External MCP server** (`ExternalMcpService`, exposed on
`cfg.ExternalMcpPort` under `/mcp`) so an external Claude session can **start and
observe** ClaudeDo work sessions end-to-end, reaching parity with the desktop UI
for those two concerns.
The server's purpose is deliberately scoped: **help the user start sessions and
observe them.** It is *not* a git/worktree console — branch merging, worktree
resets, and multi-turn continuation are things Claude does *inside* a task, so
they stay out of the tool surface.
## Scope
### In scope
**START — set up and launch a session**
- *(existing)* `AddTask`, `UpdateTask`, `UpdateTaskStatus` (Idle/Queued), `RunTaskNow`, `CancelTask`, `DeleteTask`
- **List management** — create / rename / delete lists; set working dir + default commit type
- **List & task config** — per-list defaults and per-task overrides for `model`, `system_prompt`, `agent_path`
- **Agents (read-only)** — list agent files and refresh, so Claude can choose a valid `agent_path`
- **Reset failed task** — discard the failed worktree and reset the row to Idle (the retry path)
**OBSERVE**
- *(existing)* `ListTaskLists`, `ListTasks`, `GetTask`
- **Run history** — read `task_runs` for a task (session id, tokens, turns, result, structured output, error)
- **Logs** — fetch a task's (or run's) log output
- **App settings (read-only)** — read worker app settings
### Out of scope (explicitly excluded)
- **Tags** — already removed from the system (migration `20260519044715_RemoveTags`); only the stale doc reference in `src/ClaudeDo.Worker/CLAUDE.md` needs deleting.
- **Multi-turn continue** (`--resume`) — Claude's own concern inside a task.
- **Worktree ops** — merge, merge targets, cleanup-finished, reset-all, force-remove, set-state.
- **Start planning session** — not needed via MCP.
- **App settings writes** — risky (e.g. flips permission mode); read-only only.
- **Agent file create/edit/delete** — not part of "starting a session".
## Approach (chosen: A)
**Reuse existing worker services; split the growing tool surface into focused
`[McpServerToolType]` classes.** No business logic is duplicated — each new tool
injects the same service the SignalR hub already uses, so MCP behavior stays
identical to the UI.
Adding ~12 tools to the single `ExternalMcpService` would push it past 600 lines
across eight unrelated jobs. Instead, organize tools by category, mirroring the
existing `External/` + `Planning/` layout:
| Class (new, in `External/`) | Tools | Backing service |
|---|---|---|
| `ExternalMcpService` *(existing, unchanged scope)* | task CRUD + run/cancel/status | `TaskRepository`, `QueueService`, `ITaskStateService` |
| `ListMcpTools` | `CreateList`, `RenameList`, `DeleteList`, `SetListWorkingDir` (name/dir/commitType) | `ListRepository` |
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `SetTaskConfig` (model/system_prompt/agent_path) | `ListRepository`, `TaskRepository.UpdateAgentSettingsAsync` |
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` | `TaskRunRepository`, log file read |
| `AgentMcpTools` | `ListAgents`, `RefreshAgents` | `AgentFileService.ScanAsync` |
| `LifecycleMcpTools` | `ResetFailedTask` | `TaskResetService.ResetAsync` |
| `AppSettingsMcpTools` | `GetAppSettings` | `AppSettingsRepository.GetAsync` |
(Exact class grouping may be tuned during planning, but each class stays small
and single-purpose.)
## Architecture & wiring
The external MCP server is a **separate `WebApplication`** built in
`Program.cs` (≈ lines 188217) with its own DI container, distinct from the main
SignalR app. Shared singletons (`HubBroadcaster`, `QueueService`,
`ITaskStateService`, db factory, `WorkerConfig`) are injected by instance so both
apps act on the same runtime state.
Each new tool class must be:
1. Registered in the **external** builder (`externalBuilder.Services.AddScoped<…>()`),
alongside any newly required services (`TaskRunRepository`, `AgentFileService`,
`TaskResetService` + their dependencies).
2. Registered as tools via additional `.WithTools<T>()` calls on the external
`AddMcpServer()` chain.
No change to auth: the existing `ExternalMcpAuthMiddleware` (optional
`X-ClaudeDo-Key`, loopback-only otherwise) covers all tools uniformly. No
per-tool gating — the surface is read/observe + start, with the one borderline
write (`ResetFailedTask`) being a normal retry affordance.
## Data flow
- **Start:** Claude calls e.g. `CreateList``SetListConfig``AddTask(queueImmediately: true)`. Writes go through `ListRepository` / `TaskStateService`, which wake the queue and broadcast `ListUpdated` / `TaskUpdated` so the UI reflects changes live.
- **Observe:** Claude calls `ListTasks` / `GetTask``ListRuns` / `GetRun``GetTaskLog`. Pure reads from `TaskRepository` / `TaskRunRepository` and the log file at `TaskRunEntity.LogPath`.
- **Mutations broadcast** the same SignalR events the hub raises, keeping the desktop UI in sync.
## DTOs
- `RunDto` — projection of `TaskRunEntity`: `Id`, `RunNumber`, `SessionId`, `IsRetry`, `ResultMarkdown`, `StructuredOutputJson`, `ErrorMarkdown`, `ExitCode`, `TurnCount`, `TokensIn`, `TokensOut`, `StartedAt`, `FinishedAt`.
- `AgentDto` — from `AgentInfo` (`Name`, `Description`, `Path`).
- `ListConfigDto``Model`, `SystemPrompt`, `AgentPath` (reuse the shape already used by the hub).
- App-settings read reuses the existing `AppSettingsDto` shape (read-only subset is fine).
- Log fetch returns the file contents as a string (with a size cap / tail option decided in planning).
## Error handling
Follow the existing `ExternalMcpService` convention: throw
`InvalidOperationException` with a clear message for not-found / invalid-input /
illegal-state (e.g. "List {id} not found", "Cannot reset a non-failed task").
Reuse the guard patterns already present (required-field checks, status checks).
`ResetFailedTask` must refuse non-`Failed` tasks.
## Testing
Extend `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (and add
sibling test files per new tool class) using the existing real-SQLite + real-git
integration pattern:
- List CRUD round-trips; rename/delete propagate; delete blocked/handled sensibly.
- List + task config set/get round-trips; clearing all three fields removes list config (matches hub behavior).
- Run history reads return correct projections; `GetTaskLog` returns file contents and errors cleanly when no log exists.
- `ResetFailedTask` succeeds on a Failed task and refuses other statuses.
- Agent listing reflects files on disk after refresh.
- App-settings read returns current values.
## Doc cleanup (part of this work)
- `src/ClaudeDo.Worker/CLAUDE.md` — remove the stale `SetTaskTags` / `ListTags` /
"AddTask (with tags)" claim; replace the External MCP tool inventory with the
new surface.

View File

@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
## DI Registration Pattern
- **Singletons**: SqliteConnectionFactory, all Repositories, WorkerClient, MainWindowViewModel, TaskListViewModel, TaskDetailViewModel, StatusBarViewModel
- **Transients**: TaskEditorViewModel, ListEditorViewModel (created per dialog)
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
## Notes

View File

@@ -83,6 +83,7 @@ sealed class Program
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallerLocator>();
sc.AddSingleton<WorkerLocator>();
sc.AddSingleton(sp =>
{
var releases = sp.GetRequiredService<IReleaseClient>();
@@ -102,7 +103,10 @@ sealed class Program
sc.AddTransient<PrimeClaudeTabViewModel>();
sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>();
sc.AddTransient<Func<MergeModalViewModel>>(sp => () => sp.GetRequiredService<MergeModalViewModel>());
sc.AddTransient<ListSettingsModalViewModel>();
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
// Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp =>

View File

@@ -7,18 +7,16 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
- **TagEntity** — Id (autoincrement), Name (unique)
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
## Repositories
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), tag management (`GetEffectiveTagsAsync` — union of task + list tags), `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, tag junction management, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
- **TagRepository** — `GetOrCreateAsync` (idempotent)
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `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`
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
@@ -35,7 +33,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `T
## Schema
Tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual". The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`. 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`).
## Conventions

View File

@@ -31,6 +31,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.WorktreeAutoCleanupDays)
.HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7);
builder.Property(s => s.RepoImportFolders)
.HasColumnName("repo_import_folders");
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
}
}

View File

@@ -1,12 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class ImportantFilter : ITaskListFilter
{
public string Id => "smart:important";
public bool Matches(TaskEntity t) => t.IsStarred;
public bool ShouldCount(TaskEntity t) => t.IsStarred && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -1,12 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class MyDayFilter : ITaskListFilter
{
public string Id => "smart:my-day";
public bool Matches(TaskEntity t) => t.IsMyDay;
public bool ShouldCount(TaskEntity t) => t.IsMyDay && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -1,12 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class PlannedFilter : ITaskListFilter
{
public string Id => "smart:planned";
public bool Matches(TaskEntity t) => t.ScheduledFor != null;
public bool ShouldCount(TaskEntity t) => t.ScheduledFor != null && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -1,14 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class QueuedFilter : ITaskListFilter
{
public string Id => "virtual:queued";
public bool Matches(TaskEntity t) => t.Status == TaskStatus.Queued;
public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Queued;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued);
}

View File

@@ -1,14 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class RunningFilter : ITaskListFilter
{
public string Id => "virtual:running";
public bool Matches(TaskEntity t) => t.Status == TaskStatus.Running;
public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Running;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running);
}

View File

@@ -0,0 +1,16 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Filter for a smart list keyed off a boolean/nullable task flag
/// (My Day, Important, Planned). Counts only non-done matches.
/// </summary>
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
{
public string Id => id;
public bool Matches(TaskEntity t) => flag(t);
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,18 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Virtual list filter matching tasks by a single status (Queued, Running).
/// Planning parents appear contextually when they host a matching child.
/// </summary>
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
{
public string Id => id;
public bool Matches(TaskEntity t) => t.Status == status;
public bool ShouldCount(TaskEntity t) => t.Status == status;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
}

View File

@@ -1,4 +1,5 @@
using ClaudeDo.Data.Filtering.Filters;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering;
@@ -14,11 +15,11 @@ public sealed class TaskListFilterRegistry
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal)
{
["smart:my-day"] = new MyDayFilter(),
["smart:important"] = new ImportantFilter(),
["smart:planned"] = new PlannedFilter(),
["virtual:queued"] = new QueuedFilter(),
["virtual:running"] = new RunningFilter(),
["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay),
["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred),
["smart:planned"] = new SmartFlagFilter("smart:planned", t => t.ScheduledFor != null),
["virtual:queued"] = new StatusFilter("virtual:queued", TaskStatus.Queued),
["virtual:running"] = new StatusFilter("virtual:running", TaskStatus.Running),
["virtual:review"] = new ReviewFilter(),
};

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddRepoImportFolders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "repo_import_folders",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "repo_import_folders",
value: null);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "repo_import_folders",
table: "app_settings");
}
}
}

View File

@@ -54,6 +54,10 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")

View File

@@ -15,4 +15,7 @@ public sealed class AppSettingsEntity
public string? CentralWorktreeRoot { get; set; }
public bool WorktreeAutoCleanupEnabled { get; set; }
public int WorktreeAutoCleanupDays { get; set; } = 7;
// JSON array of parent folders remembered by the repo-import modal.
public string? RepoImportFolders { get; set; }
}

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
@@ -22,7 +23,7 @@ public sealed class AppSettingsRepository
return row;
}
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
{
var row = await _context.AppSettings
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
@@ -31,6 +32,12 @@ public sealed class AppSettingsRepository
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
}
return row;
}
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
{
var row = await GetOrCreateTrackedRowAsync(ct);
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
@@ -45,4 +52,25 @@ public sealed class AppSettingsRepository
await _context.SaveChangesAsync(ct);
}
public async Task<List<string>> GetRepoImportFoldersAsync(CancellationToken ct = default)
{
var json = await _context.AppSettings.AsNoTracking()
.Where(s => s.Id == AppSettingsEntity.SingletonId)
.Select(s => s.RepoImportFolders)
.FirstOrDefaultAsync(ct);
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
catch (JsonException) { return new List<string>(); }
}
public async Task SetRepoImportFoldersAsync(IEnumerable<string> folders, CancellationToken ct = default)
{
var list = folders.ToList();
var row = await GetOrCreateTrackedRowAsync(ct);
row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list);
await _context.SaveChangesAsync(ct);
}
}

View File

@@ -203,24 +203,26 @@ public partial class App : Application
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
// Double-registered as both IInstallStep and concrete type so the Update pipeline
// can pull them out individually via GetRequiredService<T>().
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
sc.AddSingleton<RegisterAutostartStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
sc.AddSingleton<WriteUninstallRegistryStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
// Start the worker last in the fresh pipeline (binaries + task must exist first).
sc.AddSingleton<StartWorkerStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
// Pulled by Update flow + Repair/Uninstall.
sc.AddSingleton<StopServiceStep>();
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
sc.AddSingleton<StartServiceStep>();
sc.AddSingleton<StopWorkerStep>();
// Runners
sc.AddSingleton<UninstallRunner>();

View File

@@ -5,6 +5,23 @@ using ClaudeDo.Data;
namespace ClaudeDo.Installer.Core;
internal static class JsonConfigFile
{
public static T LoadOrDefault<T>(string fileName, JsonSerializerOptions readOpts) where T : new()
{
var path = Path.Combine(Paths.AppDataRoot(), fileName);
if (!File.Exists(path)) return new();
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), readOpts) ?? new();
}
public static void Save<T>(string fileName, T value, JsonSerializerOptions writeOpts)
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, fileName), JsonSerializer.Serialize(value, writeOpts));
}
}
/// <summary>
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
@@ -47,21 +64,9 @@ public sealed class InstallerWorkerConfig
};
public static InstallerWorkerConfig Load()
{
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
if (!File.Exists(path)) return new();
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
}
=> JsonConfigFile.LoadOrDefault<InstallerWorkerConfig>("worker.config.json", ReadOpts);
public void Save()
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "worker.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
public void Save() => JsonConfigFile.Save("worker.config.json", this, WriteOpts);
}
/// <summary>
@@ -85,25 +90,9 @@ public sealed class InstallerAppSettings
public static InstallerAppSettings Load()
{
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
if (!File.Exists(path)) return new();
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
}
catch
{
return new();
}
try { return JsonConfigFile.LoadOrDefault<InstallerAppSettings>("ui.config.json", ReadOpts); }
catch { return new(); }
}
public void Save()
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "ui.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
public void Save() => JsonConfigFile.Save("ui.config.json", this, WriteOpts);
}

View File

@@ -23,7 +23,6 @@ public sealed class InstallContext
public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "CurrentUser";
public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000;

View File

@@ -0,0 +1,52 @@
using System.Security;
namespace ClaudeDo.Installer.Core;
public static class ScheduledTaskXml
{
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
{
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
var user = SecurityElement.Escape(userId);
var cmd = SecurityElement.Escape(workerExePath);
return $"""
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>ClaudeDo background worker (per-user).</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{user}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<Hidden>true</Hidden>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<RestartOnFailure>
<Interval>PT{minutes}M</Interval>
<Count>3</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{cmd}</Command>
</Exec>
</Actions>
</Task>
""";
}
}

View File

@@ -9,9 +9,9 @@ namespace ClaudeDo.Installer.Core;
public sealed class UninstallRunner
{
private readonly InstallContext _context;
private readonly StopServiceStep _stopService;
private readonly StopWorkerStep _stopService;
public UninstallRunner(InstallContext context, StopServiceStep stopService)
public UninstallRunner(InstallContext context, StopWorkerStep stopService)
{
_context = context;
_stopService = stopService;
@@ -27,16 +27,17 @@ public sealed class UninstallRunner
// 2) Stop service. If stop fails we MUST abort — deleting a service whose
// process is still running leaves orphan locked binaries under the install dir
// which Directory.Delete will silently skip.
progress.Report("Stopping worker service...");
progress.Report("Stopping worker...");
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
if (!stopResult.Success)
return StepResult.Fail(
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
$"Cannot uninstall: worker did not stop cleanly. {stopResult.ErrorMessage} " +
"Kill the worker manually and re-run uninstall.");
// 3) Unregister service.
progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
// 3) Unregister the autostart task, and best-effort remove any legacy service.
progress.Report("Removing autostart task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct);
// 3b) Remove Apps & Features registry entry (best-effort).
progress.Report("Removing Add/Remove Programs entry...");

View File

@@ -10,6 +10,18 @@ using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class StepViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
[ObservableProperty] private bool _isExpanded;
public ObservableCollection<string> Messages { get; } = [];
public StepViewModel(string name) => Name = name;
}
public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
@@ -42,20 +54,23 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
Steps.Clear();
if (_context.Mode == InstallerMode.Update)
{
Steps.Add(new StepViewModel("Stop Worker Service"));
Steps.Add(new StepViewModel("Stop Worker"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Start Worker Service"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Start Worker"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
}
else
{
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Start Worker"));
}
return Task.CompletedTask;
}
@@ -116,10 +131,15 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{
steps = new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopServiceStep>(),
_serviceProvider.GetRequiredService<StopWorkerStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<StartServiceStep>(),
// Migrates the legacy service away and (re)registers the logon task.
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
_serviceProvider.GetRequiredService<StartWorkerStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
// Refresh the bundled uninstaller exe + Add/Remove-Programs version so a
// manual update also renews the installer that bootstraps future updates.
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
};
}
else

View File

@@ -1,17 +0,0 @@
using System.Collections.ObjectModel;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class StepViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
[ObservableProperty] private bool _isExpanded;
public ObservableCollection<string> Messages { get; } = [];
public StepViewModel(string name) => Name = name;
}

View File

@@ -9,8 +9,8 @@
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="Worker Service" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo Worker background service."
<TextBlock Text="Worker" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo background worker."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
@@ -33,18 +33,11 @@
<Separator Margin="0,4,0,12"/>
<Label Content="Service Account"/>
<StackPanel Margin="0,0,0,12">
<RadioButton Content="Local System (recommended)"
IsChecked="{Binding IsLocalSystem}" Margin="0,0,0,4"/>
<RadioButton Content="Current User"
IsChecked="{Binding IsCurrentUser}"/>
<TextBlock Text="Running as current user requires 'Log on as a service' privilege."
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="20,2,0,0"
TextWrapping="Wrap"/>
</StackPanel>
<TextBlock Text="The worker runs as you (the logged-in user) via a per-user logon task, so it can use your Claude CLI authentication."
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="0,0,0,12"
TextWrapping="Wrap"/>
<CheckBox Content="Start service automatically" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
<CheckBox Content="Start worker automatically at logon" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
<Label Content="Restart Delay (ms)"/>
<TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>

View File

@@ -21,8 +21,6 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
[ObservableProperty] private int _signalRPort = 47_821;
[ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
[ObservableProperty] private string _claudeBin = "claude";
[ObservableProperty] private bool _isLocalSystem = true;
[ObservableProperty] private bool _isCurrentUser;
[ObservableProperty] private bool _autoStart = true;
[ObservableProperty] private int _restartDelayMs = 5000;
[ObservableProperty] private string? _validationError;
@@ -43,7 +41,6 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
_context.SignalRPort = SignalRPort;
_context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
_context.ClaudeBin = ClaudeBin;
_context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
_context.AutoStart = AutoStart;
_context.RestartDelayMs = RestartDelayMs;
_context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";

View File

@@ -0,0 +1,59 @@
using System.IO;
using System.Security.Principal;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterAutostartStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
private const string LegacyServiceName = "ClaudeDoWorker";
public string Name => "Register Autostart";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// 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);
if (queryExit == 0)
{
progress.Report("Removing legacy worker service...");
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (q != 0) break;
await Task.Delay(1000, ct);
}
}
// 2) Register (or replace) the per-user logon task.
var userId = WindowsIdentity.GetCurrent().Name;
var minutes = Math.Max(1, ctx.RestartDelayMs / 60000);
var xml = ScheduledTaskXml.Build(userId, workerExe, minutes);
var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml");
await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct);
try
{
progress.Report("Registering logon task...");
var (exit, output) = await ProcessRunner.RunAsync(
"schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}");
}
finally
{
try { File.Delete(xmlPath); } catch { /* best effort */ }
}
return StepResult.Ok();
}
}

View File

@@ -1,88 +0,0 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterServiceStep : IInstallStep
{
private const string ServiceName = "ClaudeDoWorker";
public string Name => "Register Windows Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// Stop existing service (ignore errors — may not exist)
progress.Report("Stopping existing service (if any)...");
await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Delete existing service (ignore errors)
progress.Report("Removing existing service registration (if any)...");
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Wait for the service to actually disappear from SCM. `sc delete` returns
// immediately but the service stays "marked for deletion" until every open
// handle (services.msc, Task Manager, a prior sc query process) is closed.
// Poll up to 30s — then fail with actionable guidance if it's still there.
progress.Report("Waiting for prior service registration to clear...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
if (queryExit != 0) break; // service no longer registered — good
if (i == 29)
return StepResult.Fail(
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
"Close any open Services console (services.msc), Task Manager Services tab, or " +
"Event Viewer showing the service, then retry. A reboot will also clear it.");
await Task.Delay(1000, ct);
}
// Create service
var startType = ctx.AutoStart ? "auto" : "demand";
if (ctx.ServiceAccount == "CurrentUser")
return StepResult.Fail(
"Service cannot run as Current User without a password. " +
"Select 'Local System' or extend ServicePage to capture a password.");
var objArg = ctx.ServiceAccount switch
{
"LocalSystem" => " obj= LocalSystem",
"NetworkService" => " obj= \"NT AUTHORITY\\NetworkService\"",
"LocalService" => " obj= \"NT AUTHORITY\\LocalService\"",
_ => "",
};
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}{objArg}";
progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode == 1072)
return StepResult.Fail(
$"Service '{ServiceName}' is still marked for deletion. " +
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
if (exitCode != 0)
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
// Configure restart policy
var delay = ctx.RestartDelayMs;
var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
progress.Report("Configuring restart policy...");
var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
return StepResult.Ok();
}
private static async Task<(int ExitCode, string Output)> RunSc(
string arguments, InstallContext ctx, IProgress<string> progress,
CancellationToken ct, bool ignoreErrors = false)
{
var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
return result;
}
}

View File

@@ -1,38 +0,0 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartServiceStep : IInstallStep
{
private const string ServiceName = StopServiceStep.ServiceName;
public string Name => "Start Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Starting {ServiceName}...");
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
// 1056 = ERROR_SERVICE_ALREADY_RUNNING — fine, fall through to the readiness poll.
if (exit != 0 && exit != 1056)
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
if (exit == 1056)
progress.Report("Service was already running.");
// sc.exe start returns as soon as SCM accepts the command. Poll until the
// service actually reports RUNNING so downstream steps and SignalR clients
// don't race the worker's startup.
progress.Report("Waiting for service to reach RUNNING state...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, output) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (q == 0 && output.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
return StepResult.Ok();
await Task.Delay(1000, ct);
}
return StepResult.Fail("Service did not reach RUNNING state within 30 seconds.");
}
}

View File

@@ -0,0 +1,19 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public string Name => "Start Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Starting worker...");
var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}");
return StepResult.Ok();
}
}

View File

@@ -1,54 +0,0 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopServiceStep : IInstallStep
{
public const string ServiceName = "ClaudeDoWorker";
public string Name => "Stop Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Stopping {ServiceName} (if running)...");
// sc.exe query -> returns non-zero if the service does not exist; that's fine.
var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (queryExit != 0)
{
progress.Report("Service is not registered — nothing to stop.");
return StepResult.Ok();
}
if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service is already stopped.");
return StepResult.Ok();
}
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
if (stopExit == 1062)
{
progress.Report("Service was registered but not running.");
return StepResult.Ok();
}
if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
// Poll until stopped or timeout (up to 30s).
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(1000, ct);
var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service stopped.");
return StepResult.Ok();
}
}
return StepResult.Fail("Service did not stop within 30 seconds.");
}
}

View File

@@ -0,0 +1,48 @@
using System.Diagnostics;
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public const string ProcessName = "ClaudeDo.Worker";
public string Name => "Stop Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Stopping worker task (if running)...");
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
progress.Report("Stopping worker process (if running)...");
var installDir = ctx.InstallDirectory;
foreach (var p in Process.GetProcessesByName(ProcessName))
{
try
{
var path = p.MainModule?.FileName;
if (path is not null && !IsUnder(path, installDir)) continue;
p.Kill(entireProcessTree: true);
p.WaitForExit(10000);
}
catch { /* process may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static bool IsUnder(string filePath, string dir)
{
try
{
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
var full = Path.GetFullPath(filePath);
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
}

View File

@@ -26,14 +26,22 @@ public sealed class WriteUninstallRegistryStep : IInstallStep
// the single-file temp extract is gone once this process exits.
var sourceExe = Environment.ProcessPath
?? throw new InvalidOperationException("Cannot resolve running installer path.");
try
// In the self-update path the installer already runs from uninstaller/ (the
// --replace-self handoff put it there), so source == target and the copy would
// throw. Skip it; the binary is already in place.
var alreadyInPlace = string.Equals(
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
if (!alreadyInPlace)
{
progress.Report("Copying uninstaller binary...");
File.Copy(sourceExe, targetExe, overwrite: true);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
try
{
progress.Report("Copying uninstaller binary...");
File.Copy(sourceExe, targetExe, overwrite: true);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
}
}
progress.Report("Writing Add/Remove Programs entry...");

View File

@@ -11,8 +11,9 @@ public partial class SettingsViewModel : ObservableObject
{
private readonly InstallContext _context;
private readonly IReleaseClient _releases;
private readonly StopServiceStep _stopService;
private readonly StartServiceStep _startService;
private readonly StopWorkerStep _stopService;
private readonly StartWorkerStep _startService;
private readonly RegisterAutostartStep _registerAutostart;
private readonly DownloadAndExtractStep _downloadStep;
private readonly UninstallRunner _uninstallRunner;
@@ -37,8 +38,9 @@ public partial class SettingsViewModel : ObservableObject
PageResolver resolver,
InstallContext context,
IReleaseClient releases,
StopServiceStep stopService,
StartServiceStep startService,
StopWorkerStep stopService,
StartWorkerStep startService,
RegisterAutostartStep registerAutostart,
DownloadAndExtractStep downloadStep,
UninstallRunner uninstallRunner)
{
@@ -47,6 +49,7 @@ public partial class SettingsViewModel : ObservableObject
_releases = releases;
_stopService = stopService;
_startService = startService;
_registerAutostart = registerAutostart;
_downloadStep = downloadStep;
_uninstallRunner = uninstallRunner;
_selectedPage = Pages.FirstOrDefault();
@@ -154,7 +157,7 @@ public partial class SettingsViewModel : ObservableObject
IsStatusError = false;
var progress = new Progress<string>(msg => StatusMessage = msg);
var steps = new IInstallStep[] { _stopService, _downloadStep, _startService };
var steps = new IInstallStep[] { _stopService, _downloadStep, _registerAutostart, _startService };
foreach (var step in steps)
{

View File

@@ -6,13 +6,16 @@ public static class VersionComparer
{
public static VersionCompareResult Compare(string latest, string current)
{
var latestTrimmed = (latest ?? "").TrimStart('v', 'V');
var currentTrimmed = (current ?? "").TrimStart('v', 'V');
var unparseable = !Version.TryParse(latestTrimmed, out var lv)
| !Version.TryParse(currentTrimmed, out var cv);
var unparseable = !Version.TryParse(CoreVersion(latest), out var lv)
| !Version.TryParse(CoreVersion(current), out var cv);
if (unparseable) return new VersionCompareResult(false, true);
return new VersionCompareResult(lv > cv, false);
}
// Reduce a tag/version to its numeric core: drop a leading "v", MinVer build
// metadata ("+sha"), and any SemVer prerelease suffix ("-alpha") — none of
// which System.Version can parse. So "v1.0.2-alpha+abc" -> "1.0.2".
private static string CoreVersion(string value)
=> (value ?? "").TrimStart('v', 'V').Split('+')[0].Split('-')[0];
}

View File

@@ -17,7 +17,8 @@ MVVM with CommunityToolkit.Mvvm source generators:
- **TaskEditorView** — Modal dialog for task create/edit
- **ListEditorView** — Modal dialog for list create/edit
- **StatusBarView** — Connection status indicator, active task display
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath. Opened via context menu or gear button on a list row.
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath; also deletes the list (and its tasks) via a confirmed "Delete list" button. Opened via context menu or gear button on a list row.
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/SystemPrompt/AgentPath, showing inherited effective values. Disabled while task is running.
All views use compiled bindings (`x:DataType`).

View File

@@ -11,7 +11,6 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,8 +6,6 @@ namespace ClaudeDo.Ui.Converters;
public sealed class BoolToDraftOpacityConverter : IValueConverter
{
public static BoolToDraftOpacityConverter Instance { get; } = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? 0.7 : 1.0;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)

View File

@@ -7,8 +7,6 @@ namespace ClaudeDo.Ui.Converters;
public sealed class BoolToItalicConverter : IValueConverter
{
public static BoolToItalicConverter Instance { get; } = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? FontStyle.Italic : FontStyle.Normal;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)

View File

@@ -7,17 +7,23 @@ namespace ClaudeDo.Ui.Converters;
public sealed class DiffLineKindToBrushConverter : IValueConverter
{
private static readonly ISolidColorBrush Added = new SolidColorBrush(Color.Parse("#66BB6A"));
private static readonly ISolidColorBrush Removed = new SolidColorBrush(Color.Parse("#EF5350"));
private static readonly ISolidColorBrush Hunk = new SolidColorBrush(Color.Parse("#42A5F5"));
private static readonly ISolidColorBrush Header = new SolidColorBrush(Color.Parse("#9E9E9E"));
private static readonly ISolidColorBrush Default = new SolidColorBrush(Color.Parse("#CFD8DC"));
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is WorktreeDiffLineKind kind
? kind switch
{
WorktreeDiffLineKind.Added => new SolidColorBrush(Color.Parse("#66BB6A")),
WorktreeDiffLineKind.Removed => new SolidColorBrush(Color.Parse("#EF5350")),
WorktreeDiffLineKind.Hunk => new SolidColorBrush(Color.Parse("#42A5F5")),
WorktreeDiffLineKind.Header => new SolidColorBrush(Color.Parse("#9E9E9E")),
_ => new SolidColorBrush(Color.Parse("#CFD8DC")),
WorktreeDiffLineKind.Added => Added,
WorktreeDiffLineKind.Removed => Removed,
WorktreeDiffLineKind.Hunk => Hunk,
WorktreeDiffLineKind.Header => Header,
_ => Default,
}
: new SolidColorBrush(Color.Parse("#CFD8DC"));
: Default;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();

View File

@@ -7,17 +7,23 @@ namespace ClaudeDo.Ui.Converters;
public sealed class WorktreeStateColorConverter : IValueConverter
{
private static readonly ISolidColorBrush Active = new SolidColorBrush(Color.Parse("#42A5F5"));
private static readonly ISolidColorBrush Merged = new SolidColorBrush(Color.Parse("#66BB6A"));
private static readonly ISolidColorBrush Discarded = new SolidColorBrush(Color.Parse("#9E9E9E"));
private static readonly ISolidColorBrush Kept = new SolidColorBrush(Color.Parse("#FFA726"));
private static readonly ISolidColorBrush Default = new SolidColorBrush(Colors.Gray);
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is WorktreeState state
? state switch
{
WorktreeState.Active => new SolidColorBrush(Color.Parse("#42A5F5")),
WorktreeState.Merged => new SolidColorBrush(Color.Parse("#66BB6A")),
WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")),
WorktreeState.Kept => new SolidColorBrush(Color.Parse("#FFA726")),
_ => new SolidColorBrush(Colors.Gray),
WorktreeState.Active => Active,
WorktreeState.Merged => Merged,
WorktreeState.Discarded => Discarded,
WorktreeState.Kept => Kept,
_ => Default,
}
: new SolidColorBrush(Colors.Gray);
: Default;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();

View File

@@ -0,0 +1,61 @@
namespace ClaudeDo.Ui.Services;
public sealed class InstallerLocator : InstallArtifactLocator
{
protected override string Subdir => "uninstaller";
protected override string ExeName => "ClaudeDo.Installer.exe";
}
public sealed class WorkerLocator : InstallArtifactLocator
{
protected override string Subdir => "worker";
protected override string ExeName => "ClaudeDo.Worker.exe";
}
/// <summary>
/// Locates an executable inside a ClaudeDo install: walk up from the running
/// directory to the folder containing install.json, otherwise read the
/// uninstall registry key. Subclasses supply the subdirectory and exe name.
/// </summary>
public abstract class InstallArtifactLocator
{
private const string InstallJson = "install.json";
protected abstract string Subdir { get; }
protected abstract string ExeName { get; }
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
{
var candidate = Path.Combine(dir.FullName, Subdir, ExeName);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, Subdir, ExeName);
return File.Exists(candidate) ? candidate : null;
}
catch { return null; }
}
}

View File

@@ -1,47 +0,0 @@
namespace ClaudeDo.Ui.Services;
public sealed class InstallerLocator
{
private const string InstallJson = "install.json";
private const string InstallerExe = "ClaudeDo.Installer.exe";
private const string UninstallerSubdir = "uninstaller";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory) ?? FindByRegistry();
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
var manifest = Path.Combine(dir.FullName, InstallJson);
if (File.Exists(manifest))
{
var candidate = Path.Combine(dir.FullName, UninstallerSubdir, InstallerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, UninstallerSubdir, InstallerExe);
return File.Exists(candidate) ? candidate : null;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,8 @@
namespace ClaudeDo.Ui.Services;
public interface IPrimeScheduleApi
{
Task<List<PrimeScheduleDto>> ListAsync();
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
Task DeleteAsync(Guid id);
}

View File

@@ -15,6 +15,7 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>Raised once when the SignalR connection is first established, and again on every reconnect.</summary>
event Action? ConnectionRestoredEvent;
event Action<string>? WorktreeUpdatedEvent;
event Action<string>? ListUpdatedEvent;
event Action<string, string>? TaskMessageEvent;
event Action<string, string>? PlanningMergeStartedEvent;

View File

@@ -0,0 +1,26 @@
namespace ClaudeDo.Ui.Services;
public sealed record RepoCandidate(string Name, string FullPath);
public static class RepoScanner
{
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); }
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));
}
return result;
}
}

View File

@@ -48,7 +48,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? TaskUpdatedEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? RunNowRequestedEvent;
public event Action<string>? ListUpdatedEvent;
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
@@ -139,7 +138,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
{
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
Dispatcher.UIThread.Post(() =>
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc)));
});
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
@@ -225,9 +225,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
try { await _hub.StopAsync(); } catch { /* swallow */ }
}
/// <summary>Invoke a hub method, returning default (null) when the worker is offline or errors.</summary>
private async Task<T?> TryInvokeAsync<T>(string method, params object?[] args)
{
try { return await _hub.InvokeCoreAsync<T>(method, args); }
catch { return default; }
}
public async Task RunNowAsync(string taskId)
{
RunNowRequestedEvent?.Invoke(taskId);
await _hub.InvokeAsync("RunNow", taskId);
}
@@ -247,17 +253,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
}
public async Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
{
try
{
return await _hub.InvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
}
catch
{
return null;
}
}
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
public async Task CancelTaskAsync(string taskId)
{
@@ -270,34 +267,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
}
public async Task<List<AgentInfo>> GetAgentsAsync()
{
try
{
var agents = await _hub.InvokeAsync<List<AgentInfo>>("GetAgents");
return agents ?? [];
}
catch
{
return [];
}
}
=> await TryInvokeAsync<List<AgentInfo>>("GetAgents") ?? [];
public async Task RefreshAgentsAsync()
{
await _hub.InvokeAsync("RefreshAgents");
}
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
{
try
{
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
}
catch
{
return null;
}
}
public Task<SeedResultDto?> RestoreDefaultAgentsAsync()
=> TryInvokeAsync<SeedResultDto>("RestoreDefaultAgents");
private async Task SeedActiveTasksAsync()
{
@@ -328,17 +306,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.DisposeAsync();
}
public async Task<AppSettingsDto?> GetAppSettingsAsync()
{
try
{
return await _hub.InvokeAsync<AppSettingsDto>("GetAppSettings");
}
catch
{
return null;
}
}
public Task<AppSettingsDto?> GetAppSettingsAsync()
=> TryInvokeAsync<AppSettingsDto>("GetAppSettings");
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
{
@@ -346,16 +315,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
}
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
{
try { return await _hub.InvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules"); }
catch { return new List<PrimeScheduleDto>(); }
}
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
{
try { return await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto); }
catch { return null; }
}
public Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
=> TryInvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
public async Task DeletePrimeScheduleAsync(Guid id)
{
@@ -373,17 +336,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("UpdateListConfig", dto);
}
public async Task<ListConfigDto?> GetListConfigAsync(string listId)
{
try
{
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId);
}
catch
{
return null;
}
}
public Task<ListConfigDto?> GetListConfigAsync(string listId)
=> TryInvokeAsync<ListConfigDto>("GetListConfig", listId);
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
{
@@ -395,66 +349,35 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
}
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
{
try
{
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
}
catch
{
return null;
}
}
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
{
try
{
return await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
}
catch
{
return null;
}
}
public Task<WorktreeResetDto?> ResetAllWorktreesAsync()
=> TryInvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
?? new List<WorktreeOverviewDto>();
public async Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState)
{
try
{
var rows = await _hub.InvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId);
return rows ?? new List<WorktreeOverviewDto>();
var ok = await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
return (ok, null);
}
catch
catch (HubException ex)
{
return new List<WorktreeOverviewDto>();
return (false, ex.Message);
}
catch (Exception)
{
return (false, "Worker offline.");
}
}
public async Task<bool> SetWorktreeStateAsync(string taskId, WorktreeState newState)
{
try
{
return await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
}
catch
{
return false;
}
}
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
{
try
{
return await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
}
catch
{
return null;
}
}
public Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
=> TryInvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
@@ -475,29 +398,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
{
try
{
var result = await _hub.InvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId);
return result ?? [];
}
catch
{
return [];
}
}
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
{
try
{
return await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
}
catch
{
return null;
}
}
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
{

View File

@@ -1,12 +1,5 @@
namespace ClaudeDo.Ui.Services;
public interface IPrimeScheduleApi
{
Task<List<PrimeScheduleDto>> ListAsync();
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
Task DeleteAsync(Guid id);
}
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
{
private readonly WorkerClient _client;

View File

@@ -13,6 +13,37 @@ using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.ViewModels.Islands;
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }
public sealed class LogLineViewModel
{
public required LogKind Kind { get; init; }
public required string Text { get; init; }
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
public string KindMarker => Kind switch
{
LogKind.Sys => "sys",
LogKind.Tool => "tool",
LogKind.Claude => "claude",
LogKind.Stdout => "out",
LogKind.Stderr => "err",
LogKind.Done => "done",
LogKind.Msg => "claude",
_ => "",
};
public string ClassName => Kind switch
{
LogKind.Sys => "log-sys",
LogKind.Tool => "log-tool",
LogKind.Claude => "log-claude",
LogKind.Stdout => "log-stdout",
LogKind.Stderr => "log-stderr",
LogKind.Done => "log-done",
LogKind.Msg => "log-msg",
_ => "",
};
}
public sealed partial class DetailsIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -24,6 +55,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
[NotifyCanExecuteChangedFor(nameof(ReviewCombinedDiffCommand))]
[NotifyPropertyChangedFor(nameof(TaskIdBadge))]
private TaskRowViewModel? _task;
// Editable fields
@@ -31,8 +64,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[ObservableProperty] private string _editableDescription = "";
[ObservableProperty] private bool _isEditingDescription;
[ObservableProperty] private bool _isDescriptionExpanded = true;
[ObservableProperty] private string _notes = "";
[ObservableProperty] private string _promptInput = "";
public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription;
public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription;
@@ -175,10 +206,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Set by the view so OpenDiffCommand can show the modal as a dialog
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
// Set by the view so OpenWorktreeCommand can show the modal as a dialog
public Func<WorktreeModalViewModel, System.Threading.Tasks.Task>? ShowWorktreeModal { get; set; }
// Set by the view so ApproveMergeCommand can show the modal as a dialog
// Set by the view so OpenDiff can pass through merge requests from the diff modal
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
@@ -223,7 +251,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
DequeueCommand.NotifyCanExecuteChanged();
ResetAndRetryCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
ApproveMergeCommand.NotifyCanExecuteChanged();
}
};
@@ -427,7 +454,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_subscribedTaskId = null;
EditableTitle = "";
EditableDescription = "";
Notes = "";
Model = null;
WorktreePath = null;
WorktreeStateLabel = null;
@@ -473,7 +499,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_suppressDescSave = true;
try { EditableDescription = entity.Description ?? ""; }
finally { _suppressDescSave = false; }
Notes = entity.Notes ?? "";
Model = entity.Model;
WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
@@ -737,29 +762,55 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
ApproveMergeCommand.NotifyCanExecuteChanged();
}
partial void OnWorktreeStateLabelChanged(string? value)
{
ApproveMergeCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
private async System.Threading.Tasks.Task SendPromptAsync()
{
if (string.IsNullOrWhiteSpace(PromptInput) || Task == null) return;
Log.Add(new LogLineViewModel { Kind = LogKind.Msg, Text = $"[you] {PromptInput}" });
// TODO: WorkerClient has no SendPromptAsync — no matching hub method found.
// When the worker gains a "SendPrompt" hub method, call:
// await _worker.SendPromptAsync(Task.Id, PromptInput);
PromptInput = "";
await System.Threading.Tasks.Task.CompletedTask;
}
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
[RelayCommand]
private async System.Threading.Tasks.Task ToggleStarAsync()
{
if (Task is null) return;
Task.IsStarred = !Task.IsStarred;
await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
var entity = await repo.GetByIdAsync(Task.Id);
if (entity is null) return;
entity.IsStarred = Task.IsStarred;
await repo.UpdateAsync(entity);
}
[RelayCommand]
private async System.Threading.Tasks.Task ToggleDoneAsync()
{
if (Task is null) return;
Task.Done = !Task.Done;
await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
var entity = await repo.GetByIdAsync(Task.Id);
if (entity is null) return;
entity.Status = Task.Done
? ClaudeDo.Data.Models.TaskStatus.Done
: ClaudeDo.Data.Models.TaskStatus.Idle;
Task.Status = entity.Status;
AgentStatusLabel = entity.Status.ToString();
await repo.UpdateAsync(entity);
}
[RelayCommand]
private async System.Threading.Tasks.Task ToggleSubtaskDoneAsync(SubtaskRowViewModel? row)
{
if (row is null) return;
row.Done = !row.Done;
await using var ctx = _dbFactory.CreateDbContext();
var repo = new SubtaskRepository(ctx);
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
var entity = subs.FirstOrDefault(s => s.Id == row.Id);
if (entity is null) return;
entity.Completed = row.Done;
await repo.UpdateAsync(entity);
}
[RelayCommand]
private async System.Threading.Tasks.Task DeleteTaskAsync()
{
@@ -813,30 +864,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
NewSubtaskTitle = "";
}
[RelayCommand]
private async System.Threading.Tasks.Task SaveNotesAsync()
{
if (Task == null) return;
await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
var entity = await repo.GetByIdAsync(Task.Id);
if (entity == null) return;
entity.Notes = Notes;
await repo.UpdateAsync(entity);
}
[RelayCommand(CanExecute = nameof(CanMerge))]
private async System.Threading.Tasks.Task ApproveMergeAsync()
{
if (Task == null || ShowMergeModal == null) return;
var vm = _services.GetRequiredService<MergeModalViewModel>();
await vm.InitializeAsync(Task.Id, Task.Title);
await ShowMergeModal(vm);
}
private bool CanMerge() =>
Task != null && _worker.IsConnected && WorktreePath != null && WorktreeStateLabel == "Active";
[RelayCommand]
private async System.Threading.Tasks.Task StopAsync()
{
@@ -857,7 +884,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
}
private bool CanEnqueue() =>
Task != null && _worker.IsConnected && IsIdle;
Task != null && _worker.IsConnected && IsIdle
&& (!Task.IsChild || Task.ParentFinalized);
[RelayCommand(CanExecute = nameof(CanDequeue))]
private async System.Threading.Tasks.Task DequeueAsync()

View File

@@ -29,6 +29,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
[RelayCommand]
private async Task OpenSettings()
@@ -47,19 +48,38 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
await ShowListSettingsModal(vm);
await RefreshRowAsync(row.Id);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id);
}
[RelayCommand]
private async System.Threading.Tasks.Task OpenRepoImportAsync()
{
if (ShowRepoImportModal is null || _services is null) return;
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
await LoadAsync();
}
private bool _worktreesOverviewOpen;
[RelayCommand]
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
{
if (row is null || ShowWorktreesOverviewModal is null || _services is null) return;
if (row.Kind != ListKind.User) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
if (_worktreesOverviewOpen) return;
_worktreesOverviewOpen = true;
try
{
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
}
finally { _worktreesOverviewOpen = false; }
}
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
@@ -91,6 +111,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
}
}
@@ -205,7 +226,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType);
await ShowListSettingsModal(vm);
await RefreshRowAsync(item.Id);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(item.Id);
}
}

View File

@@ -1,32 +0,0 @@
namespace ClaudeDo.Ui.ViewModels.Islands;
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }
public sealed class LogLineViewModel
{
public required LogKind Kind { get; init; }
public required string Text { get; init; }
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
public string KindMarker => Kind switch
{
LogKind.Sys => "sys",
LogKind.Tool => "tool",
LogKind.Claude => "claude",
LogKind.Stdout => "out",
LogKind.Stderr => "err",
LogKind.Done => "done",
LogKind.Msg => "claude",
_ => "",
};
public string ClassName => Kind switch
{
LogKind.Sys => "log-sys",
LogKind.Tool => "log-tool",
LogKind.Claude => "log-claude",
LogKind.Stdout => "log-stdout",
LogKind.Stderr => "log-stderr",
LogKind.Done => "log-done",
LogKind.Msg => "log-msg",
_ => "",
};
}

View File

@@ -28,6 +28,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _isExpanded = true;
[ObservableProperty] private bool _hasPlanningChildren;
[ObservableProperty] private bool _hasQueuedSubtasks;
[ObservableProperty] private bool _showListChip = true;
[ObservableProperty] private bool _parentFinalized;
public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
@@ -38,7 +40,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|| HasPlanningChildren;
public bool IsDraft => IsChild && Status == TaskStatus.Idle;
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
&& PlanningPhase == PlanningPhase.None
@@ -52,6 +56,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
_ => null,
};
public bool IsPlanActive => PlanningPhase == PlanningPhase.Active;
public bool IsPlanFinalized => PlanningPhase == PlanningPhase.Finalized;
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
public bool HasSteps => StepsCount > 0;
@@ -60,7 +67,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks;
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized);
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
public bool CanQueuePlan => !IsChild && HasPlanningChildren
&& PlanningPhase == PlanningPhase.Finalized
&& !HasQueuedSubtasks;
public bool HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
@@ -86,23 +98,44 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(HasLiveTail));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnParentFinalizedChanged(bool value)
{
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
}
partial void OnPlanningPhaseChanged(PlanningPhase value)
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(IsPlanActive));
OnPropertyChanged(nameof(IsPlanFinalized));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
OnPropertyChanged(nameof(CanQueuePlan));
}
partial void OnHasQueuedSubtasksChanged(bool value)
{
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanQueuePlan));
}
partial void OnBlockedByTaskIdChanged(string? value)
@@ -112,14 +145,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(StatusChipClass));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnHasPlanningChildrenChanged(bool value)
=> OnPropertyChanged(nameof(IsPlanningParent));
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(CanQueuePlan));
}
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));

View File

@@ -57,6 +57,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage;
_worker.ListUpdatedEvent += OnWorkerListUpdated;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
}
}
@@ -67,6 +68,29 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
if (row is not null) row.LiveTail = line;
}
private async void OnWorkerListUpdated(string listId)
{
// Mirror the renamed list onto every task row that references it,
// so the per-row ListName chip on virtual lists stays current.
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
if (entity is null) return;
var visibleIds = Items.Select(r => r.Id).ToHashSet();
if (visibleIds.Count == 0) return;
var matchingIds = await db.Tasks.AsNoTracking()
.Where(t => t.ListId == listId && visibleIds.Contains(t.Id))
.Select(t => t.Id)
.ToListAsync();
var matching = matchingIds.ToHashSet();
foreach (var row in Items)
if (matching.Contains(row.Id) && row.ListName != entity.Name)
row.ListName = entity.Name;
}
catch { }
}
private async void OnWorkerTaskUpdated(string taskId)
{
var list = _currentList;
@@ -192,8 +216,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
filteredList.Add(c);
}
var showListChip = list.Kind == ListKind.Virtual;
foreach (var t in filteredList)
Items.Add(TaskRowViewModel.FromEntity(t));
{
var row = TaskRowViewModel.FromEntity(t);
row.ShowListChip = showListChip;
Items.Add(row);
}
// Mark any top-level row that has at least one child as a planning parent,
// so its subtasks remain expandable even after the parent is queued/running.
@@ -215,6 +244,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
foreach (var r in Items)
r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id);
// A subtask is "Planned" (queueable) once its planning parent is finalized;
// until then it is a "Draft".
var finalizedParents = Items
.Where(r => r.PlanningPhase == PlanningPhase.Finalized)
.Select(r => r.Id)
.ToHashSet();
foreach (var r in Items)
r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
&& finalizedParents.Contains(r.ParentTaskId!);
Regroup();
UpdateSubtitle();
}
@@ -342,6 +381,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
db.Tasks.Add(entity);
await db.SaveChangesAsync();
var row = TaskRowViewModel.FromEntity(entity);
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
Items.Add(row);
Regroup();
NewTaskTitle = "";
@@ -615,7 +655,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
await _worker.ResumePlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningModalResult.FinalizeNow:
await _worker.FinalizePlanningSessionAsync(row.Id);
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
break;
case UnfinishedPlanningModalResult.Discard:
await TryDiscardPlanningWithRetryAsync(row.Id);
@@ -683,7 +723,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); }
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
catch { }
}

View File

@@ -29,10 +29,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
private readonly UpdateCheckService _updateCheck;
private readonly InstallerLocator _installerLocator;
private readonly UpdateCheckService _updateCheck = null!;
private readonly InstallerLocator _installerLocator = null!;
private readonly WorkerLocator _workerLocator = null!;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
// Set by MainWindow to open the conflict resolution dialog.
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
@@ -40,6 +45,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
// Set by MainWindow to open the About dialog.
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
// Set by MainWindow to open the repo-import dialog.
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
// Set by MainWindow to open the global worktrees overview dialog.
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
@@ -163,14 +171,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
WorkerClient worker,
UpdateCheckService updateCheck,
InstallerLocator installerLocator,
WorkerLocator workerLocator,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory)
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory)
{
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
_updateCheck = updateCheck;
_installerLocator = installerLocator;
_workerLocator = workerLocator;
_dbFactory = dbFactory;
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
_mergeVmFactory = mergeVmFactory;
_repoImportVmFactory = repoImportVmFactory;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
@@ -207,6 +221,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
_primeStatusTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
_ = Lists.LoadAsync();
_ = EnsureWorkerRunningAsync();
_updateCheck.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
@@ -255,14 +270,31 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
if (ShowAboutModal is not null) await ShowAboutModal(vm);
}
[RelayCommand]
private async Task OpenRepoImport()
{
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
var vm = _repoImportVmFactory();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
if (Lists is not null) await Lists.LoadAsync();
}
private bool _worktreesOverviewOpen;
[RelayCommand]
private async Task OpenWorktreesOverviewGlobalAsync()
{
if (ShowWorktreesOverviewModal is null) return;
var vm = _worktreesOverviewVmFactory();
vm.Configure(null, null);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
if (ShowWorktreesOverviewModal is null || _worktreesOverviewOpen) return;
_worktreesOverviewOpen = true;
try
{
var vm = _worktreesOverviewVmFactory();
vm.Configure(null, null);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
}
finally { _worktreesOverviewOpen = false; }
}
[RelayCommand]
@@ -273,42 +305,60 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
[ObservableProperty] private string? _restartWorkerStatus;
private bool _ensureRunningAttempted;
private async Task EnsureWorkerRunningAsync()
{
if (_ensureRunningAttempted) return;
_ensureRunningAttempted = true;
await Task.Delay(TimeSpan.FromSeconds(4));
if (Worker?.IsConnected == true) return;
var exe = _workerLocator.Find();
if (exe is null) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* logon task is the primary mechanism; this is a convenience */ }
}
[RelayCommand]
private async Task RestartWorkerAsync()
{
if (!OperatingSystem.IsWindows())
{
await FlashRestartStatusAsync("Service control is Windows-only.");
return;
}
RestartWorkerStatus = "Restarting worker…";
try
{
await Task.Run(() =>
{
using var sc = new System.ServiceProcess.ServiceController("ClaudeDoWorker");
if (sc.Status != System.ServiceProcess.ServiceControllerStatus.Stopped)
{
sc.Stop();
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(20));
}
sc.Start();
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Running, TimeSpan.FromSeconds(20));
});
await Task.Run(RestartWorkerService);
await FlashRestartStatusAsync("Worker restarted.");
}
catch (InvalidOperationException)
{
// ServiceController throws this when the service is not installed.
await FlashRestartStatusAsync("ClaudeDoWorker service is not installed.");
}
catch (Exception ex)
{
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
}
}
private void RestartWorkerService()
{
var exe = _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
// Only kill the worker belonging to THIS installation — not any other
// ClaudeDo.Worker on the machine (e.g. a second install).
var exeFull = System.IO.Path.GetFullPath(exe);
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{
try
{
var path = p.MainModule?.FileName;
if (path is not null &&
!string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase))
continue;
p.Kill(entireProcessTree: true);
p.WaitForExit(10000);
}
catch { /* may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
}
private async Task FlashRestartStatusAsync(string text)
{
RestartWorkerStatus = text;

View File

@@ -1,17 +1,28 @@
using System.Collections.ObjectModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class ListSettingsModalViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public string ListId { get; set; } = "";
// True after the list was deleted, so the caller reloads the list nav instead of refreshing the row.
public bool Deleted { get; private set; }
// Wired by the view to prompt yes/no before deleting and to surface a blocking-FK error.
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
public Func<string, Task>? ShowErrorAsync { get; set; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _workingDir = "";
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
@@ -29,9 +40,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
public ListSettingsModalViewModel(WorkerClient worker)
public ListSettingsModalViewModel(WorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_worker = worker;
_dbFactory = dbFactory;
}
public async Task LoadAsync(
@@ -78,6 +90,37 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
CloseAction?.Invoke();
}
[RelayCommand]
private async Task DeleteAsync()
{
var displayName = string.IsNullOrWhiteSpace(Name) ? "Untitled" : Name;
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync($"Delete list \"{displayName}\" and all its tasks? This cannot be undone.");
if (!ok) return;
}
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
await lists.DeleteAsync(ListId);
}
catch (Exception ex) when (
(ex is Microsoft.Data.Sqlite.SqliteException
|| ex.InnerException is Microsoft.Data.Sqlite.SqliteException)
&& (ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
{
if (ShowErrorAsync is not null)
await ShowErrorAsync("This list has planning sessions with child tasks. Discard those first, then delete the list.");
return;
}
Deleted = true;
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();

View File

@@ -14,15 +14,15 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public ObservableCollection<string> Branches { get; } = new();
[ObservableProperty] private string? _selectedBranch;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private string? _selectedBranch;
[ObservableProperty] private bool _removeWorktree = true;
[ObservableProperty] private string _commitMessage = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
[ObservableProperty] private string? _errorMessage;
[ObservableProperty] private string? _warningMessage;
[ObservableProperty] private string? _successMessage;
[ObservableProperty] private bool _hasConflict;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _hasConflict;
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
public Action? CloseAction { get; set; }

View File

@@ -0,0 +1,206 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportItemViewModel : ViewModelBase
{
public string Name { get; init; } = "";
public string FullPath { get; init; } = "";
// True when a list already points at this path. Such rows are shown ticked + disabled.
public bool AlreadyAdded { get; init; }
public bool CanToggle => !AlreadyAdded;
[ObservableProperty] private bool _isChecked;
// Driven by the search filter; the row collapses when it doesn't match.
[ObservableProperty] private bool _isVisible = true;
}
public sealed partial class RepoImportModalViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _folders = new();
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
public Action? CloseAction { get; set; }
[ObservableProperty] private string _searchText = "";
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
public bool CanCreate => CreateCount > 0;
public string CreateButtonText => $"Create {CreateCount} list(s)";
public bool HasFolders => _folders.Count > 0;
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task LoadAsync(CancellationToken ct = default)
{
ClearRepos();
_existingDirs.Clear();
_folders.Clear();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
foreach (var l in await lists.GetAllAsync(ct))
{
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
_existingDirs.Add(l.WorkingDir!);
}
var settings = new AppSettingsRepository(ctx);
foreach (var f in await settings.GetRepoImportFoldersAsync(ct))
AddFolderToSet(f);
ScanAndAdd(_folders);
OnPropertyChanged(nameof(HasFolders));
NotifyCreateState();
}
public async Task AddFoldersAsync(IEnumerable<string> folders)
{
var added = new List<string>();
foreach (var f in folders)
if (AddFolderToSet(f)) added.Add(f);
if (added.Count == 0) return;
ScanAndAdd(added);
OnPropertyChanged(nameof(HasFolders));
NotifyCreateState();
await SaveFoldersAsync();
}
private void ScanAndAdd(IEnumerable<string> folders)
{
var current = new HashSet<string>(
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
foreach (var folder in folders)
{
foreach (var item in BuildCandidates(RepoScanner.Scan(folder), current, _existingDirs))
{
item.PropertyChanged += OnItemChanged;
Repos.Add(item);
current.Add(item.FullPath);
}
}
ApplyFilter();
}
public static List<RepoImportItemViewModel> BuildCandidates(
IEnumerable<RepoCandidate> found,
IReadOnlySet<string> currentPaths,
IReadOnlySet<string> existingDirs)
{
var items = new List<RepoImportItemViewModel>();
foreach (var c in found)
{
if (currentPaths.Contains(c.FullPath)) continue;
var alreadyAdded = existingDirs.Contains(c.FullPath);
items.Add(new RepoImportItemViewModel
{
Name = c.Name,
FullPath = c.FullPath,
AlreadyAdded = alreadyAdded,
// New repos start unchecked (no auto-select); already-added rows show ticked + disabled.
IsChecked = alreadyAdded,
});
}
return items;
}
[RelayCommand]
private async Task ForgetFoldersAsync()
{
_folders.Clear();
ClearRepos();
OnPropertyChanged(nameof(HasFolders));
NotifyCreateState();
await SaveFoldersAsync();
}
partial void OnSearchTextChanged(string value) => ApplyFilter();
private void ApplyFilter()
{
var q = SearchText?.Trim() ?? "";
foreach (var r in Repos)
r.IsVisible = q.Length == 0
|| r.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| r.FullPath.Contains(q, StringComparison.OrdinalIgnoreCase);
}
private bool AddFolderToSet(string folder)
{
if (string.IsNullOrWhiteSpace(folder)) return false;
if (_folders.Any(f => string.Equals(f, folder, StringComparison.OrdinalIgnoreCase)))
return false;
_folders.Add(folder);
return true;
}
private async Task SaveFoldersAsync()
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(_folders);
}
private void ClearRepos()
{
foreach (var r in Repos) r.PropertyChanged -= OnItemChanged;
Repos.Clear();
}
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
NotifyCreateState();
}
private void NotifyCreateState()
{
OnPropertyChanged(nameof(CreateCount));
OnPropertyChanged(nameof(CanCreate));
OnPropertyChanged(nameof(CreateButtonText));
}
[RelayCommand]
private async Task CreateAsync()
{
var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList();
if (toCreate.Count > 0)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
foreach (var r in toCreate)
{
await lists.AddAsync(new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = r.Name,
WorkingDir = r.FullPath,
DefaultCommitType = CommitTypeRegistry.DefaultType,
CreatedAt = DateTime.UtcNow,
});
}
}
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}

View File

@@ -15,15 +15,15 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
{
[ObservableProperty] private string _taskId = "";
[ObservableProperty] private string _taskTitle = "";
[ObservableProperty] private TaskStatus _taskStatus;
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsRunning))] private TaskStatus _taskStatus;
[ObservableProperty] private string _listId = "";
[ObservableProperty] private string _listName = "";
[ObservableProperty] private string _path = "";
[ObservableProperty] private string _branchName = "";
[ObservableProperty] private string _baseCommit = "";
[ObservableProperty] private WorktreeState _state;
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsActive))] private WorktreeState _state;
[ObservableProperty] private string? _diffStat;
[ObservableProperty] private DateTime _createdAt;
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
[ObservableProperty] private bool _pathExistsOnDisk;
[ObservableProperty] private bool _isSelected;
@@ -66,6 +66,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
public Action<string, string>? JumpToTaskAction { get; set; }
public Func<string, Task<bool>>? ConfirmAction { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
{
@@ -103,7 +105,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
Groups.Clear();
if (IsGlobal)
{
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName)).OrderBy(g => g.Key.ListName))
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
{
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
foreach (var row in grp) group.Rows.Add(row);
@@ -158,10 +161,21 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
private void OpenInExplorer(WorktreeOverviewRowViewModel? row)
{
if (row is null || !row.PathExistsOnDisk) return;
try { Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{row.Path}\"", UseShellExecute = true }); }
try { Process.Start(new ProcessStartInfo { FileName = row.Path, UseShellExecute = true }); }
catch { }
}
[RelayCommand]
private async Task Merge(WorktreeOverviewRowViewModel? row)
{
if (row is null || row.State != WorktreeState.Active) return;
if (ResolveMergeVm is null || ShowMergeAction is null) return;
var mergeVm = ResolveMergeVm();
await mergeVm.InitializeAsync(row.TaskId, row.TaskTitle);
await ShowMergeAction(mergeVm);
await LoadAsync();
}
[RelayCommand]
private void JumpToTask(WorktreeOverviewRowViewModel? row)
{
@@ -174,16 +188,18 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
private async Task Discard(WorktreeOverviewRowViewModel? row)
{
if (row is null || row.State != WorktreeState.Active) return;
if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded))
row.State = WorktreeState.Discarded;
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded);
if (ok) row.State = WorktreeState.Discarded;
else StatusMessage = err ?? "Failed to discard worktree.";
}
[RelayCommand]
private async Task Keep(WorktreeOverviewRowViewModel? row)
{
if (row is null || row.State != WorktreeState.Active) return;
if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept))
row.State = WorktreeState.Kept;
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept);
if (ok) row.State = WorktreeState.Kept;
else StatusMessage = err ?? "Failed to keep worktree.";
}
[RelayCommand]

View File

@@ -154,7 +154,7 @@
Foreground="{DynamicResource TextDimBrush}"
Margin="0,0,8,0"/>
<TextBox Grid.Column="1" x:Name="TimeInput"
Watermark="HH:mm" MaxLength="5"
PlaceholderText="HH:mm" MaxLength="5"
Text="{Binding #Root.TimeText, Mode=TwoWay}"/>
<Button Grid.Column="2" Content="Done"
Click="OnDoneClick"

View File

@@ -81,6 +81,7 @@
<Button Grid.Column="2"
Classes="icon-btn"
ToolTip.Tip="Copy path"
Click="OnCopyWorktreePathClick"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
</Button>

View File

@@ -1,8 +1,19 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands;
public partial class AgentStripView : UserControl
{
public AgentStripView() { InitializeComponent(); }
private async void OnCopyWorktreePathClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null || string.IsNullOrEmpty(vm.WorktreePath)) return;
await clipboard.SetTextAsync(vm.WorktreePath);
}
}

View File

@@ -38,13 +38,16 @@
<!-- ── Header (sticky top): check · eyebrow · title · status · star · gear ── -->
<Border DockPanel.Dock="Top" Classes="island-header">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Ellipse Grid.Column="0"
Classes="task-check"
Classes.done="{Binding Task.Done}"
Width="18" Height="18"
VerticalAlignment="Top"
Margin="0,2,10,0"
Cursor="Hand"/>
<Button Grid.Column="0" Classes="flat"
Command="{Binding ToggleDoneCommand}"
Padding="0"
VerticalAlignment="Top"
Margin="0,2,10,0">
<Ellipse Classes="task-check"
Classes.done="{Binding Task.Done}"
Width="18" Height="18"
Cursor="Hand"/>
</Button>
<StackPanel Grid.Column="1" Spacing="0">
<TextBlock Text="{Binding TaskIdBadge}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
@@ -62,6 +65,8 @@
<Button Grid.Column="2"
Classes="icon-btn star-btn"
Classes.on="{Binding Task.IsStarred}"
Command="{Binding ToggleStarCommand}"
ToolTip.Tip="Star"
VerticalAlignment="Top"
Margin="6,0,0,0">
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
@@ -176,13 +181,17 @@
<Border Classes="subtask-row"
Classes.done="{Binding Done}">
<Grid ColumnDefinitions="Auto,*">
<Ellipse Grid.Column="0"
Classes="task-check"
Classes.done="{Binding Done}"
Width="16" Height="16"
VerticalAlignment="Center"
Cursor="Hand"
Margin="0,0,8,0"/>
<Button Grid.Column="0" Classes="flat"
Padding="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).ToggleSubtaskDoneCommand}"
CommandParameter="{Binding}">
<Ellipse Classes="task-check"
Classes.done="{Binding Done}"
Width="16" Height="16"
Cursor="Hand"/>
</Button>
<TextBlock Grid.Column="1"
Classes="subtask-title"
Text="{Binding Title}"

View File

@@ -30,14 +30,6 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner);
};
vm.ShowWorktreeModal = async (worktreeVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new WorktreeModalView { DataContext = worktreeVm };
await modal.ShowDialog(owner);
};
vm.ShowMergeModal = async (mergeVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -143,12 +135,6 @@ public partial class DetailsIslandView : UserControl
return await tcs.Task;
}
private void NotesLostFocus(object? sender, RoutedEventArgs e)
{
if (DataContext is DetailsIslandViewModel vm)
vm.SaveNotesCommand.Execute(null);
}
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm) return;

View File

@@ -168,19 +168,28 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- + New list button -->
<Button Classes="new-list-btn" Margin="0,4,0,0"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="New list" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="New list" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding OpenRepoImportCommand}"
ToolTip.Tip="Add repos as lists">
<PathIcon Data="{StaticResource Icon.Folder}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid>
</StackPanel>
</ScrollViewer>

View File

@@ -1,6 +1,10 @@
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals;
@@ -22,32 +26,50 @@ public partial class ListsIslandView : UserControl
{
var window = new ListSettingsModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
modal.ConfirmAsync = ShowConfirmAsync;
modal.ShowErrorAsync = ShowErrorDialogAsync;
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
vm.ShowRepoImportModal = async modal =>
{
var window = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
vm.ShowWorktreesOverviewModal = async modal =>
{
var top = TopLevel.GetTopLevel(this) as Window;
var shell = top?.DataContext as IslandsShellViewModel;
var window = new WorktreesOverviewModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
modal.JumpToTaskAction = (listId, _) =>
modal.JumpToTaskAction = (listId, taskId) =>
{
if (vm is { } v)
{
var item = v.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
if (item is not null) v.SelectedList = item;
}
if (shell is not null)
_ = JumpToTaskAsync(shell, listId, taskId);
};
modal.ShowDiffAction = diffVm =>
{
var top2 = TopLevel.GetTopLevel(this) as Window;
if (top2 is null) return;
if (top is null) return;
var dlg = new WorktreeModalView { DataContext = diffVm };
diffVm.CloseAction = () => dlg.Close();
_ = diffVm.LoadAsync();
_ = dlg.ShowDialog(top2);
_ = dlg.ShowDialog(top);
};
var top = TopLevel.GetTopLevel(this) as Window;
modal.ConfirmAction = ShowConfirmAsync;
if (shell is not null)
{
modal.ResolveMergeVm = shell.ResolveMergeVm;
modal.ShowMergeAction = async mergeVm =>
{
if (top is null) return;
var mergeDlg = new MergeModalView { DataContext = mergeVm };
await mergeDlg.ShowDialog(top);
};
}
if (top is null) window.Show();
else await window.ShowDialog(top);
};
@@ -69,4 +91,88 @@ public partial class ListsIslandView : UserControl
var modal = new SettingsModalView { DataContext = settingsVm };
await modal.ShowDialog(owner);
}
private static System.Threading.Tasks.Task JumpToTaskAsync(IslandsShellViewModel s, string listId, string taskId)
=> JumpToTaskHelper.SelectAsync(s, listId, taskId);
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var ok = new Button { Content = "OK", MinWidth = 90 };
var dialog = new Window
{
Title = "Error",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { ok },
},
},
},
};
ok.Click += (_, _) => dialog.Close();
await dialog.ShowDialog(owner);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
}

View File

@@ -60,7 +60,7 @@
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="Queue subtasks sequentially"
Click="OnQueuePlanningSubtasksClick"
IsVisible="{Binding HasPlanningChildren}"/>
IsVisible="{Binding CanQueuePlan}"/>
<Separator/>
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
<MenuItem Header="Clear schedule"
@@ -113,7 +113,13 @@
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
<TextBlock Text="DRAFT"/>
</Border>
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
<Border Classes="badge planned" IsVisible="{Binding IsPlanned}">
<TextBlock Text="PLANNED"/>
</Border>
<Border Classes="badge"
Classes.planning="{Binding IsPlanActive}"
Classes.planned="{Binding IsPlanFinalized}"
IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/>
</Border>
</StackPanel>
@@ -141,7 +147,7 @@
</Button>
<!-- List chip with dot -->
<Border Classes="chip chip-list">
<Border Classes="chip chip-list" IsVisible="{Binding ShowListChip}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse Width="6" Height="6"
Fill="{DynamicResource MossBrush}"

View File

@@ -51,7 +51,7 @@
Foreground="{DynamicResource TextFaintBrush}"/>
</Border>
<TextBox Grid.Column="1" x:Name="AddTaskBox" Classes="add-task-input"
Watermark="Add a task…"
PlaceholderText="Add a task…"
Text="{Binding NewTaskTitle, Mode=TwoWay}"
VerticalAlignment="Center"
Margin="12,0,0,0">

View File

@@ -0,0 +1,42 @@
using System.Collections.Specialized;
using System.Linq;
using System.Threading.Tasks;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
internal static class JumpToTaskHelper
{
// Selects the list, then waits for the matching task row to appear in Tasks.Items.
// Listens to CollectionChanged so it works regardless of how long LoadForListAsync takes;
// bounded by a hard timeout so we never hang if the task is filtered out.
public static async Task SelectAsync(IslandsShellViewModel s, string listId, string taskId)
{
if (s.Lists is null || s.Tasks is null) return;
var item = s.Lists.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
if (item is null) return;
s.Lists.SelectedList = item;
var existing = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
if (existing is not null) { s.Tasks.SelectedTask = existing; return; }
var tcs = new TaskCompletionSource<bool>();
void OnChanged(object? _, NotifyCollectionChangedEventArgs __)
{
var row = s.Tasks!.Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) tcs.TrySetResult(true);
}
s.Tasks.Items.CollectionChanged += OnChanged;
try
{
await Task.WhenAny(tcs.Task, Task.Delay(5000));
var row = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) s.Tasks.SelectedTask = row;
}
finally
{
s.Tasks.Items.CollectionChanged -= OnChanged;
}
}
}

View File

@@ -10,7 +10,7 @@
Background="{DynamicResource VoidBrush}"
Icon="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico"
CanResize="True"
SystemDecorations="BorderOnly"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1">
<Window.Resources>
@@ -72,6 +72,7 @@
<MenuItem Header="Worktrees…"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
</MenuItem>
</Menu>
</StackPanel>

View File

@@ -1,6 +1,9 @@
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
@@ -38,13 +41,10 @@ public partial class MainWindow : Window
{
var dlg = new WorktreesOverviewModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
modal.JumpToTaskAction = (listId, _) =>
modal.JumpToTaskAction = (listId, taskId) =>
{
if (DataContext is IslandsShellViewModel s)
{
var item = s.Lists?.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
if (item is not null && s.Lists is not null) s.Lists.SelectedList = item;
}
_ = JumpToTaskAsync(s, listId, taskId);
};
modal.ShowDiffAction = diffVm =>
{
@@ -53,6 +53,19 @@ public partial class MainWindow : Window
_ = diffVm.LoadAsync();
_ = diffDlg.ShowDialog(this);
};
modal.ConfirmAction = ShowConfirmAsync;
modal.ResolveMergeVm = vm.ResolveMergeVm;
modal.ShowMergeAction = async mergeVm =>
{
var mergeDlg = new MergeModalView { DataContext = mergeVm };
await mergeDlg.ShowDialog(this);
};
await dlg.ShowDialog(this);
};
vm.ShowRepoImportModal = async (modal) =>
{
var dlg = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
}
@@ -85,4 +98,48 @@ public partial class MainWindow : Window
base.OnSizeChanged(e);
if (DataContext is IslandsShellViewModel vm) vm.WindowWidth = Bounds.Width;
}
private static System.Threading.Tasks.Task JumpToTaskAsync(IslandsShellViewModel s, string listId, string taskId)
=> JumpToTaskHelper.SelectAsync(s, listId, taskId);
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(this);
return await tcs.Task;
}
}

View File

@@ -4,8 +4,8 @@
x:Class="ClaudeDo.Ui.Views.Modals.AboutModalView"
x:DataType="vm:AboutModalViewModel"
Title="About ClaudeDo"
Width="480" Height="280"
SystemDecorations="None"
Width="620" Height="280"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
@@ -23,8 +23,8 @@
Command="{Binding CloseCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" Padding="20,16">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10">
<ScrollViewer Grid.Row="1" Padding="20,16" HorizontalScrollBarVisibility="Disabled">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10" ColumnSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" FontFamily="{DynamicResource MonoFont}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Data" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>

View File

@@ -5,7 +5,7 @@
x:DataType="vm:DiffModalViewModel"
Title="Diff"
Width="1200" Height="800"
SystemDecorations="None"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{StaticResource SurfaceBrush}">

View File

@@ -7,7 +7,7 @@
Width="520" Height="720"
CanResize="True"
MinWidth="460" MinHeight="520"
SystemDecorations="None"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
@@ -42,6 +42,10 @@
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="{DynamicResource BloodBrush}"/>
<Setter Property="Foreground" Value="White"/>
</Style>
</Window.Styles>
<Border Background="{DynamicResource SurfaceBrush}"
@@ -89,7 +93,7 @@
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Working directory"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" Watermark="(none)" />
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="(none)" />
<Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" />
</Grid>
</StackPanel>
@@ -168,12 +172,14 @@
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" MinWidth="90"/>
</StackPanel>
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center" Margin="16,0">
<Button Grid.Column="0" Content="Delete list" Classes="danger"
Command="{Binding DeleteCommand}" MinWidth="90"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" MinWidth="90"/>
</StackPanel>
</Grid>
</Border>
</Grid>

View File

@@ -6,7 +6,7 @@
Title="Merge worktree"
Width="560" Height="460"
CanResize="False"
SystemDecorations="None"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">

View File

@@ -0,0 +1,83 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
x:DataType="vm:RepoImportModalViewModel"
Title="Add repos as lists"
Width="560" Height="480"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
<Grid RowDefinitions="36,Auto,*,52">
<!-- Header -->
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="ADD REPOS AS LISTS" FontFamily="{DynamicResource MonoFont}" FontSize="11"
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
Command="{Binding CancelCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Toolbar: search + folder actions -->
<StackPanel Grid.Row="1" Spacing="8" Margin="16,12,16,6">
<TextBox Text="{Binding SearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="Search repos…"/>
<Grid ColumnDefinitions="Auto,*,Auto">
<Button Grid.Column="0" Content="Add folder…" Click="AddFolderClicked"/>
<Button Grid.Column="2" Content="Forget folders"
Command="{Binding ForgetFoldersCommand}"
IsVisible="{Binding HasFolders}"/>
</Grid>
</StackPanel>
<!-- Repo checklist -->
<ScrollViewer Grid.Row="2" Padding="16,2,16,8">
<ItemsControl ItemsSource="{Binding Repos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:RepoImportItemViewModel">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,1"
IsVisible="{Binding IsVisible}">
<CheckBox Grid.Column="0" MinWidth="0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding CanToggle}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Name}"
Foreground="{DynamicResource TextBrush}" FontSize="12"
VerticalAlignment="Center" Margin="4,0,0,0"/>
<TextBlock Grid.Column="2" Text="{Binding FullPath}"
Foreground="{DynamicResource TextFaintBrush}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBlock Grid.Column="3" Text="(already added)"
Foreground="{DynamicResource TextFaintBrush}" FontSize="10"
VerticalAlignment="Center" Margin="8,0,0,0"
IsVisible="{Binding AlreadyAdded}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="3" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="primary"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,30 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class RepoImportModalView : Window
{
public RepoImportModalView()
{
InitializeComponent();
}
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not RepoImportModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose folders containing repos",
AllowMultiple = true,
});
if (folders.Count == 0) return;
await vm.AddFoldersAsync(folders.Select(f => f.Path.LocalPath));
}
}

View File

@@ -8,7 +8,7 @@
x:DataType="vm:SettingsModalViewModel"
Title="Settings"
Width="580" Height="760"
SystemDecorations="None"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
@@ -94,7 +94,7 @@
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Default instructions"/>
<TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110"
Watermark="Baseline instructions applied to every task"
PlaceholderText="Baseline instructions applied to every task"
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*,12,*">
@@ -133,7 +133,7 @@
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="Central worktree root"/>
<TextBox Text="{Binding Worktrees.CentralWorktreeRoot, Mode=TwoWay}"
Watermark="e.g. C:\worktrees"/>
PlaceholderText="e.g. C:\worktrees"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8">

View File

@@ -6,7 +6,7 @@
Title="Unfinished planning session"
Width="440" Height="200"
CanResize="False"
SystemDecorations="None"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">

View File

@@ -8,7 +8,7 @@
Width="1100" Height="720"
MinWidth="640" MinHeight="400"
WindowStartupLocation="CenterOwner"
SystemDecorations="BorderOnly"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
Background="Transparent"

View File

@@ -9,7 +9,7 @@
CanResize="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"
SystemDecorations="BorderOnly"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1">
@@ -32,6 +32,10 @@
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).JumpToTaskCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="Merge…"
IsEnabled="{Binding IsActive}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).MergeCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="Discard"
IsEnabled="{Binding IsActive}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).DiscardCommand}"

View File

@@ -5,7 +5,7 @@
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
Title="Merge conflict"
Width="560" SizeToContent="Height"
SystemDecorations="None"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{StaticResource SurfaceBrush}">

View File

@@ -5,7 +5,7 @@
x:DataType="vm:PlanningDiffViewModel"
Title="Planning — Combined diff"
Width="1100" Height="700"
SystemDecorations="None"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{StaticResource SurfaceBrush}">

View File

@@ -17,6 +17,8 @@ Worker/
Hub/ — WorkerHub, HubBroadcaster
```
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
## Architecture
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
@@ -24,7 +26,14 @@ Worker/
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask`
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
- `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only)
## Status Model

View File

@@ -10,7 +10,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
</ItemGroup>
@@ -23,6 +24,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>WinExe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View File

@@ -0,0 +1,31 @@
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record AppSettingsReadDto(
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
string WorktreeStrategy, string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
[McpServerToolType]
public sealed class AppSettingsMcpTools
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
{
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var row = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
return new AppSettingsReadDto(
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
row.WorktreeStrategy, row.CentralWorktreeRoot,
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
}
}

View File

@@ -0,0 +1,64 @@
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
[McpServerToolType]
public sealed class ConfigMcpTools
{
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster;
public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
{
_lists = lists;
_tasks = tasks;
_broadcaster = broadcaster;
}
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")]
public async Task<TaskConfigDto?> GetListConfig(string listId, CancellationToken cancellationToken)
{
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath);
}
[McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")]
public async Task SetListConfig(
string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var m = model.NullIfBlank();
var sp = systemPrompt.NullIfBlank();
var ap = agentPath.NullIfBlank();
if (m is null && sp is null && ap is null)
await _lists.DeleteConfigAsync(listId, cancellationToken);
else
await _lists.SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap,
}, cancellationToken);
await _broadcaster.ListUpdated(listId);
}
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null for any field to clear that override.")]
public async Task SetTaskConfig(
string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class AgentMcpTools
{
private readonly AgentFileService _agents;
public AgentMcpTools(AgentFileService agents) => _agents = agents;
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
=> await _agents.ScanAsync(cancellationToken);
}
[McpServerToolType]
public sealed class LifecycleMcpTools
{
private readonly TaskRepository _tasks;
private readonly TaskResetService _reset;
public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
{
_tasks = tasks;
_reset = reset;
}
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")]
public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Failed)
throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
await _reset.ResetAsync(taskId, cancellationToken);
}
}

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