diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..3407af2
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git add:*)",
+ "Bash(git commit:*)"
+ ]
+ }
+}
diff --git a/.superpowers/brainstorm/1955-1776152447/content/color-green-teal.html b/.superpowers/brainstorm/1955-1776152447/content/color-green-teal.html
new file mode 100644
index 0000000..b7e22ca
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/color-green-teal.html
@@ -0,0 +1,106 @@
+
Green-Teal Variations
+Steel Teal shifted greener. Pick the one that feels right.
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ + Add a task...
+
+
+
+
+
Forest Teal
+
Accent: #3d9474. Distinctly greener, still muted. Earthy.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ + Add a task...
+
+
+
+
+
Jade
+
Accent: #4a9880. Balanced green-teal midpoint. Calm but not cold.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ + Add a task...
+
+
+
+
+
Sage
+
Accent: #5a9a7a. Most green of the three. Softer, natural tone.
+
+
+
diff --git a/.superpowers/brainstorm/1955-1776152447/content/color-theme.html b/.superpowers/brainstorm/1955-1776152447/content/color-theme.html
new file mode 100644
index 0000000..2408cee
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/color-theme.html
@@ -0,0 +1,107 @@
+Accent Color: Which tone?
+You want something dimmer than the indigo (#6366f1) I showed. Here are darker, more muted options — each shown on a task list mockup.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ + Add a task...
+
+
+
+
+
Slate Blue
+
Muted blue-gray. Accent: #4b5ea8. Very subdued, professional. Close to VS Code's dark theme feel.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+ + Add a task...
+
+
+
+
+
Dim Violet
+
Muted purple. Accent: #7c6aad. Slightly warmer, still understated. Has a subtle "Claude" vibe.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+ + Add a task...
+
+
+
+
+
Steel Teal
+
Muted teal-green. Accent: #4a8c8c. Cool and calm. Distinct from typical blue-heavy dark UIs.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+ + Add a task...
+
+
+
+
+
Charcoal Blue
+
Desaturated steel blue. Accent: #5571a1. Very close to Microsoft To Do's dark mode accent but dimmer.
+
+
+
diff --git a/.superpowers/brainstorm/1955-1776152447/content/layout-design.html b/.superpowers/brainstorm/1955-1776152447/content/layout-design.html
new file mode 100644
index 0000000..19c82de
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/layout-design.html
@@ -0,0 +1,169 @@
+Layout & Visual Design Direction
+Your current UI has button toolbars and minimal spacing. Which direction should we take?
+
+
+
+
+
+
+
+
+
Lists
+
+
My Project
+
Backend Work
+
UI Polish
+
+
+ +
+ E
+ -
+
+
+
+
+
Tasks
+
+
+
+
Fix login bug
+
agent
+
+
+ Running
+ Run
+
+
+
+
+
+ + Task
+ Edit
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
Lists
+
+
+
+ My Project
+
+
+
+ Backend Work
+
+
+
+ UI Polish
+
+
+
+
+
+
+
My Project
+
+
+
+
+
Fix login bug
+
agent · Running
+
+
+
+
+
+
Add dark mode
+
manual
+
+
+
+
+
+
Setup CI pipeline
+
agent · Done
+
+
+
+
+
+
+
+
+
+
+
+Key Changes in the Proposed Design
+
+
+
1
+
+
Circular Checkboxes
+
Replace status badges with circular checkboxes on the left. Border color reflects status (orange=running, green=done, gray=manual). Click to toggle done.
+
+
+
+
2
+
+
Inline "Add a task" Input
+
Dashed border text field pinned at the bottom of the task list. Always visible. Enter to create, Escape to cancel.
+
+
+
+
3
+
+
List Name as Tasks Header
+
Replace generic "Tasks" header with the selected list name in larger text. Matches To Do's pattern.
+
+
+
+
4
+
+
Sidebar Polish
+
Colored dots per list, subtle highlight on selected, "+ New List" link at bottom instead of +/E/- buttons.
+
+
+
+
5
+
+
Remove Button Toolbars
+
Eliminate the bottom button bars from both panes. All actions via context menu, keyboard shortcuts, or inline controls.
+
+
+
+
6
+
+
Completed Task Styling
+
Done tasks get strikethrough text, reduced opacity, green checkmark. Keeps them visible but visually subordinate.
+
+
+
+This is multi-select — pick all the changes you'd like to include. I recommend all 6.
diff --git a/.superpowers/brainstorm/1955-1776152447/content/task-creation-flow.html b/.superpowers/brainstorm/1955-1776152447/content/task-creation-flow.html
new file mode 100644
index 0000000..b26220e
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/task-creation-flow.html
@@ -0,0 +1,61 @@
+Task Creation: How should adding tasks work?
+Microsoft To Do uses an inline text field at the bottom of the task list. Currently ClaudeDo opens a modal dialog. Which approach fits best?
+
+
+
+
A
+
+
Inline Add (To Do style)
+
A text field always visible at the bottom of the task list. Press Enter to create a quick task with just a title. Tab or click to expand for more fields (description, tags, status).
+
+
Pros
+ Fastest for rapid task entry
+ Keyboard-driven — never leave the list
+ Feels natural and lightweight
+
+
Cons
+ Limited space for advanced fields
+ Need separate flow for setting tags/status on creation
+
+
+
+
+
+
+
B
+
+
Inline Add + Detail Pane Editing
+
Same inline text field for quick creation. After pressing Enter, the new task is selected and the detail pane on the right becomes editable — add description, tags, commit type there. Like To Do's "click task → edit in sidebar" pattern.
+
+
Pros
+ Quick entry AND full editing without modals
+ Uses existing detail pane real estate
+ Closest to Microsoft To Do's actual flow
+
+
Cons
+ Detail pane needs to become editable (currently read-only)
+ More complex state management
+
+
+
+
+
+
+
C
+
+
Keep Modal Dialog + Keyboard Shortcut
+
Keep the existing modal editor but add Ctrl+N / Enter shortcut to open it instantly. Add keyboard navigation within the dialog (Tab between fields, Enter to save).
+
+
Pros
+ Minimal code changes
+ All fields visible at once
+ Modal keeps focus clear
+
+
Cons
+ Still interrupts flow with a window
+ Feels heavier than To Do
+
+
+
+
+
diff --git a/.superpowers/brainstorm/1955-1776152447/content/waiting.html b/.superpowers/brainstorm/1955-1776152447/content/waiting.html
new file mode 100644
index 0000000..ef07652
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/waiting.html
@@ -0,0 +1,3 @@
+
+
Continuing in terminal...
+
diff --git a/.superpowers/brainstorm/1955-1776152447/state/server-stopped b/.superpowers/brainstorm/1955-1776152447/state/server-stopped
new file mode 100644
index 0000000..00b0c44
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/state/server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1776154800894}
diff --git a/.superpowers/brainstorm/1955-1776152447/state/server.pid b/.superpowers/brainstorm/1955-1776152447/state/server.pid
new file mode 100644
index 0000000..141d29f
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/state/server.pid
@@ -0,0 +1 @@
+1955
diff --git a/.superpowers/brainstorm/3761-1776156321/content/island-colors.html b/.superpowers/brainstorm/3761-1776156321/content/island-colors.html
new file mode 100644
index 0000000..c8dec88
--- /dev/null
+++ b/.superpowers/brainstorm/3761-1776156321/content/island-colors.html
@@ -0,0 +1,79 @@
+Island-Farben: weniger grün
+Gleiche Struktur, neutralere Grautöne mit nur einem Hauch Grün
+
+
+
+
+
+
Neutral Slate
+
Base: #1b1e23 · Islands: #252a30 Fast kein Grün — kühl, neutral, wie VS Code Dark+
+
+
+
+
+
+
+
Warm Charcoal
+
Base: #1c1e21 · Islands: #272a2e Minimal warm, komplett neutral. Wie Rider's New UI Dark.
+
+
+
+
+
+
+
Subtle Tint
+
Base: #1b1f22 · Islands: #262b2d Ganz leichter kühler Ton — zwischen den anderen beiden
+
+
+
diff --git a/.superpowers/brainstorm/3761-1776156321/content/island-layout.html b/.superpowers/brainstorm/3761-1776156321/content/island-layout.html
new file mode 100644
index 0000000..c7ba7fe
--- /dev/null
+++ b/.superpowers/brainstorm/3761-1776156321/content/island-layout.html
@@ -0,0 +1,77 @@
+Island Layout (Rider-Style)
+Dark greenish-gray base, rounded card panels floating on top
+
+
+
+
+
+
+
+
Lists
+
My Project
+
Backend
+
+
+
+
My Project
+
Fix login bug
+
+
+
+
+
+
+
+
+
+
+
+
+
Lists
+
My Project
+
Backend
+
+
+
My Project
+
+
+
+
Fix login bug
+
agent · manual
+
+
+
+
+
+
Fix login bug
+
Status
+
Manual
+
Tags
+
+ agent
+
+
+
+
+
+
+
+Die Änderungen
+
+ Window-Background: #1a2420 — dunkles Grau mit Grünstich
+ Island-Background: #222d29 — etwas heller, ebenfalls grünlich
+ Border-Radius: 12px auf allen drei Spalten
+ Gap: 8px zwischen den Islands (GridSplitter entfernen, Margin nutzen)
+ Padding: 8px um das gesamte Grid (Window-Rand)
+ GridSplitter weg — die Islands haben feste Abstände, Resizing via Window-Größe
+
diff --git a/.superpowers/brainstorm/3761-1776156321/state/server-stopped b/.superpowers/brainstorm/3761-1776156321/state/server-stopped
new file mode 100644
index 0000000..429ce2f
--- /dev/null
+++ b/.superpowers/brainstorm/3761-1776156321/state/server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1776158304159}
diff --git a/.superpowers/brainstorm/3761-1776156321/state/server.pid b/.superpowers/brainstorm/3761-1776156321/state/server.pid
new file mode 100644
index 0000000..1a4b4d0
--- /dev/null
+++ b/.superpowers/brainstorm/3761-1776156321/state/server.pid
@@ -0,0 +1 @@
+3761
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..1e8a0c4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,54 @@
+# ClaudeDo
+
+A desktop task management app that executes tasks autonomously via Claude CLI in isolated git worktrees.
+
+## Architecture
+
+Two-process system communicating over SignalR (`127.0.0.1:47821`):
+
+- **ClaudeDo.App** — Avalonia desktop entry point, DI container setup
+- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
+- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
+- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
+- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
+
+## Tech Stack
+
+- .NET 8.0, Avalonia 12.0.0 (Fluent theme)
+- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
+- SignalR for real-time IPC
+- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
+- Git worktrees for task isolation
+
+## Key Paths
+
+- DB: `~/.todo-app/todo.db`
+- UI config: `~/.todo-app/ui.config.json`
+- Worker config: `~/.todo-app/worker.config.json`
+- Logs: `~/.todo-app/logs/`
+- Worktrees: configured per worker (sibling or central strategy)
+- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
+
+## Conventions
+
+- Repository pattern — each entity has its own async repository
+- All data operations are async with CancellationToken support
+- Task status flow: Manual | Queued -> Running -> Done | Failed
+- Worktree state flow: Active -> Merged | Discarded | Kept
+- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
+- Commit messages use conventional format: `{commitType}(slug): title`
+- Views use compiled bindings (`x:DataType`)
+- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
+
+## Building & Testing
+
+```bash
+dotnet build ClaudeDo.slnx
+dotnet test tests/ClaudeDo.Worker.Tests
+```
+
+## Docs
+
+- `docs/plan.md` — full architecture and design spec
+- `docs/open.md` — verification checklist and improvement backlog
+- `docs/improvement-plan.md` — prioritized improvement items
diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx
index e680914..785aab8 100644
--- a/ClaudeDo.slnx
+++ b/ClaudeDo.slnx
@@ -6,6 +6,7 @@
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e7b36be
--- /dev/null
+++ b/README.md
@@ -0,0 +1,107 @@
+# ClaudeDo
+
+A desktop task management app that executes tasks autonomously via [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) in isolated git worktrees.
+
+Queue up coding tasks, and ClaudeDo picks them up one by one — each running in its own worktree so your main branch stays clean.
+
+## Architecture
+
+Two-process system communicating over SignalR:
+
+| Project | Role |
+|---|---|
+| **ClaudeDo.App** | Avalonia desktop entry point, DI container setup |
+| **ClaudeDo.Ui** | Views, ViewModels, SignalR client (MVVM) |
+| **ClaudeDo.Data** | SQLite data layer, repositories, models, GitService |
+| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
+
+```
+┌──────────────┐ SignalR ┌──────────────┐
+│ ClaudeDo.App│◄──────────►│ClaudeDo.Worker│
+│ (Avalonia) │ 127.0.0.1 │ (ASP.NET) │
+│ │ :47821 │ │
+│ ┌──────────┐│ │ ┌──────────┐ │
+│ │ Ui ││ │ │ TaskQueue│ │
+│ │(ViewModels)│ │ │ Claude CLI│ │
+│ └──────────┘│ │ └──────────┘ │
+└──────┬───────┘ └──────┬───────┘
+ │ │
+ └───────────┬───────────────┘
+ │
+ ┌──────┴──────┐
+ │ ClaudeDo.Data│
+ │ (SQLite) │
+ └─────────────┘
+```
+
+## Tech Stack
+
+- .NET 8.0
+- Avalonia 12.0.0 (Fluent theme)
+- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
+- SignalR for real-time IPC between UI and Worker
+- CommunityToolkit.Mvvm for source-generated MVVM
+- Git worktrees for task isolation
+
+## Prerequisites
+
+- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
+- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
+- Git
+
+## Getting Started
+
+```bash
+# Build
+dotnet build ClaudeDo.slnx
+
+# Run tests
+dotnet test tests/ClaudeDo.Worker.Tests
+
+# Run the app
+dotnet run --project src/ClaudeDo.App
+```
+
+## How It Works
+
+1. Create a task in the UI and tag it with **"agent"** to mark it for automated execution.
+2. The Worker picks up queued tasks and runs each one via Claude CLI in an isolated git worktree.
+3. When done, the worktree can be merged, kept for review, or discarded.
+
+**Task status flow:** `Manual | Queued → Running → Done | Failed`
+
+**Worktree state flow:** `Active → Merged | Discarded | Kept`
+
+## Configuration
+
+All data and config lives under `~/.todo-app/`:
+
+| File | Purpose |
+|---|---|
+| `todo.db` | SQLite database |
+| `ui.config.json` | UI settings |
+| `worker.config.json` | Worker settings (worktree strategy, etc.) |
+| `logs/` | Application logs |
+
+## Project Structure
+
+```
+ClaudeDo.slnx
+├── src/
+│ ├── ClaudeDo.App/ # Desktop entry point
+│ ├── ClaudeDo.Ui/ # Views & ViewModels
+│ ├── ClaudeDo.Data/ # Data access layer
+│ └── ClaudeDo.Worker/ # Background task runner
+├── tests/
+│ └── ClaudeDo.Worker.Tests/
+├── schema/
+│ └── schema.sql # Database schema
+└── docs/
+ ├── plan.md # Architecture & design spec
+ ├── open.md # Verification checklist & backlog
+ └── improvement-plan.md # Prioritized improvements
+```
+
+## License
+
+Private — not licensed for redistribution.
diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md
new file mode 100644
index 0000000..261e8f3
--- /dev/null
+++ b/docs/improvement-plan.md
@@ -0,0 +1,92 @@
+# ClaudeDo — Improvement Plan (Session 2026-04-13)
+
+Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
+
+---
+
+## P1 — UX-Blocker (sollten zuerst)
+
+### IP-1: UI ↔ Worker Auto-Reconnect
+**Symptom:** Wenn UI vor Worker startet, bleibt die Verbindung tot. Manueller UI-Restart nötig.
+**Soll:** SignalR-Client mit `WithAutomaticReconnect()` + Reconnect-Versuche im Hintergrund (exponential backoff). Status-Bar zeigt "verbinde…" während Retry.
+**Dateien:** `src/ClaudeDo.Ui/Services/WorkerClient.cs` (oder wo `HubConnection` gebaut wird)
+**Aufwand:** klein (~30 Zeilen, primär `HubConnectionBuilder`-Konfig + Reconnect-Handler)
+**Risiko:** klein
+
+### IP-2: Listen-Modus „Notes" (non-autonomous)
+**Symptom:** Jede Liste ist Agent-gesteuert. Keine reine Notiz-Liste möglich.
+**Soll:** Neues Feld `lists.kind` (`agent` | `notes`).
+- `agent`: aktuelles Verhalten (Worker pickt Tasks)
+- `notes`: Worker ignoriert die Liste komplett, UI versteckt Run-/Schedule-/Worktree-Felder, Tasks haben nur Title + Description + done-Checkbox.
+**Dateien:**
+- Schema: neue Spalte + Migration (siehe IP-9)
+- `Data/Entities/TaskList.cs`, `Repositories/ListRepository.cs`
+- `Worker/Queue/QueueService.cs` (Filter `WHERE list.kind = 'agent'`)
+- UI: `ListEditorView` (Radio/ComboBox), `TaskListView` (conditional Columns), `TaskDetailView` (verstecken)
+**Aufwand:** mittel (~Schema + Repo + UI an mehreren Stellen)
+**Risiko:** mittel — bestehende Listen müssen Default `agent` bekommen
+
+### IP-3: Doppelklick öffnet Edit-Dialog
+**Symptom:** Edit nur über separaten Button/Menüpunkt.
+**Soll:** `DoubleTapped`-Handler auf ListBox-Items (Listen-Pane) und auf TaskRows (Task-Pane) → öffnet jeweiligen Editor.
+**Dateien:** `Views/MainWindow.axaml(.cs)`, `Views/TaskListView.axaml(.cs)`
+**Aufwand:** klein (~10–15 Zeilen pro Stelle)
+**Risiko:** klein
+
+### IP-4: Tag-Multi-Select statt Freitext
+**Symptom:** Tags müssen getippt werden, keine Auto-Vervollständigung, Typos möglich.
+**Soll:** Multi-Select-Control:
+- Zeigt alle in DB existierenden Tags (DISTINCT aus `lists.tags` ∪ `tasks.tags`)
+- Erlaubt Anlegen neuer Tags (Free-Text-Add)
+- Chip/Token-Darstellung der ausgewählten Tags
+**Dateien:**
+- *neu* `Views/Controls/TagPicker.axaml` (wiederverwendbar)
+- `ListEditorView`, `TaskEditorView` einbinden
+- Repo-Methode `GetAllKnownTagsAsync()`
+**Aufwand:** mittel (Custom-Control lohnt sich, da 2× verwendet)
+**Risiko:** klein
+
+### IP-5: Rechtsklick-Kontextmenü
+**Symptom:** Quick-Actions nur über Buttons im Detail-Pane oder Toolbar.
+**Soll:**
+- **Liste:** Edit, Delete, New Task, ggf. „Mark all done" (für Notes-Listen aus IP-2)
+- **Task:** Edit, Delete, Run Now, Show Diff, Merge, Cancel (je nach Status)
+- Items kontext-sensitiv enabled/disabled je nach Task-Status & List-Kind
+**Dateien:** `Views/MainWindow.axaml` (List-Pane), `Views/TaskListView.axaml` (Task-Pane)
+**Aufwand:** klein–mittel — Avalonia `ContextMenu` + Command-Bindings
+**Risiko:** klein
+
+---
+
+## P2 — Folge-Arbeiten (durch P1 ausgelöst)
+
+### IP-6: Schema-Migration-Mechanismus
+**Trigger:** IP-2 fügt eine Spalte zu `lists` hinzu. Aktuell `schema.sql` ist Drop-and-Create-Style.
+**Soll:** Mini-Migrations-System: `migrations/0001_initial.sql`, `0002_lists_kind.sql`, … + `_schema_version` Tabelle.
+**Aufwand:** klein–mittel
+**Querverweis:** `open.md` Sektion 7 (Schulden-Tabelle: „Embedded schema.sql ohne Versionierung")
+
+### IP-7: Status-Bar zeigt Reconnect-State
+**Trigger:** IP-1 — User soll sehen, dass Verbindung gerade aufgebaut wird (statt nur „offline").
+**Soll:** States: `connected` | `connecting` | `reconnecting` | `offline`. Farb-codiert.
+**Datei:** `ViewModels/StatusBarViewModel.cs`
+**Aufwand:** klein
+
+### IP-8: Tag-Repository für `GetAllKnownTagsAsync`
+**Trigger:** IP-4 braucht eine Quelle aller bekannten Tags.
+**Soll:** Methode in `ListRepository`/`TaskRepository` ODER neuer `TagRepository`. SQL: `SELECT DISTINCT trim(value) FROM lists, json_each(lists.tags) UNION ...`.
+**Aufwand:** klein
+
+---
+
+## Empfohlene Reihenfolge
+
+1. **IP-1** (Auto-Reconnect) — sofortiger UX-Win, isoliert, klein
+2. **IP-3** (Doppelklick) — trivial, sofort spürbar
+3. **IP-5** (Kontextmenü) — kompakt, hebt Bedienkomfort deutlich
+4. **IP-6** (Migrations) — Voraussetzung für IP-2
+5. **IP-2** (Notes-Mode) — größerer Brocken, braucht Schema-Migration
+6. **IP-8 → IP-4** (Tag-Repo, dann Multi-Select-Control)
+7. **IP-7** (Reconnect-Status in StatusBar) — Polish nach IP-1
+
+Block 1 (IP-1, IP-3, IP-5) ist ein realistischer Session-Block.
diff --git a/docs/open.md b/docs/open.md
new file mode 100644
index 0000000..06cb836
--- /dev/null
+++ b/docs/open.md
@@ -0,0 +1,193 @@
+# ClaudeDo — Offene Punkte
+
+Stand: 2026-04-13 nach Slice F. Branch `main` @ `48e4aab`. Alle Tests grün (38/38), Build 0 Warnings.
+
+Dieses Dokument listet alles, was noch fehlt — gruppiert nach Aufwand/Risiko und mit konkreten Datei-Pointern, damit wir es in der IDE der Reihe nach durchgehen können.
+
+---
+
+## 1. Verification (vor allem anderen)
+
+Die in `plan.md` definierten Verification-Steps sind teilweise nur durch Build/Tests abgedeckt. Diese sollten manuell einmal durchlaufen werden, BEVOR wir Polish bauen — damit wir wissen, was tatsächlich kaputt ist.
+
+| # | Plan | Status | Was tun |
+|---|------|--------|---------|
+| 1 | Schema-Init | Auto verifiziert (Worker startet ohne Crash, WAL-Files entstehen) | OK |
+| 1a | SignalR-Endpoint | Manuell verifiziert (HTTP 400 auf `/hub` ohne Handshake) | OK |
+| 1b | Hub-Roundtrip `Ping` | **Nicht getestet** | Test-Client schreiben oder UI starten und im Log nach "pong" schauen |
+| 2 | `claude --version` Preflight | **Nicht implementiert** | `Worker/Program.cs`: vor `app.Run()` einmal `claude --version` shellen und bei Exit≠0 abbrechen |
+| 3 | Smoke-Spawn (`claude -p` mit Prompt "ping") | **Nicht getestet** | Integrationstest schreiben oder einmal manuell laufen lassen |
+| 4 | E2E Happy Path (Non-Worktree) | **Nicht getestet** | UI starten → Liste "Test" anlegen → Task mit Tag `agent` + Status `queued` + Description "Schreibe ein Haiku über Intralogistik" → Run abwarten → Result prüfen |
+| 5 | Worktree Happy Path | **Nicht getestet** | Manueller Test mit echtem Repo (z.B. einem temp-Repo) |
+| 6 | No-Changes-Run | **Nicht getestet** | Prompt der nichts ändert → `head_commit` bleibt NULL |
+| 7 | Kein Git-Repo | **Nicht getestet** | working_dir auf `C:\Temp` → Task `failed`, keine `worktrees`-Row |
+| 8 | Merge-UI | **Nicht getestet** (UI ruft `GitService.MergeFfOnlyAsync`, aber nie ausgeführt) | Manuell |
+| 9 | Override-Parallelität | Tests vorhanden für Slot-Logik, **End-to-End nicht** | UI: zwei Tasks queuen, `Run Now` auf der zweiten → beide laufen parallel |
+| 10 | Schedule | Logik per Test abgedeckt, **End-to-End nicht** | Task mit `scheduled_for = now+2min` |
+| 11 | Worker-Offline-Erkennung | UI hat Status-Bar, aber **nicht visuell verifiziert** | Worker killen, schauen ob Status auf "offline" wechselt |
+| 12 | Live-Stream | **Nicht getestet** | Während Run TaskDetail öffnen, beobachten ob ndjson-Zeilen erscheinen |
+| 13 | Wake-up (UI ruft `WakeQueue` nach Anlage) | Implementiert in `TaskListViewModel`, **nicht visuell verifiziert** | Tasks nach Anlage in <1s gepickert |
+
+**Vorschlag:** Wir machen einmal Step 4 (Haiku-Happy-Path) gemeinsam — wenn das läuft, ist die ganze Pipeline lebendig.
+
+---
+
+## 2. UI-Polish (kritisch für Benutzbarkeit)
+
+Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` markiert. Reihenfolge nach Schmerz:
+
+### 2.1 Folder-Picker für `Working Directory`
+- **Datei:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml` + `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs`
+- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
+- **Soll:** Button "…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
+- **Aufwand:** klein, ~30 Zeilen.
+
+### 2.2 Delete-Confirmation
+- **Dateien:** `MainWindowViewModel.DeleteList`, `TaskListViewModel.DeleteTask`
+- **Aktuell:** löscht direkt ohne Rückfrage. Datenverlust-Risiko.
+- **Soll:** Mini-Dialog "Wirklich löschen?" mit Ja/Nein.
+- **Aufwand:** klein, generisches `ConfirmDialog` lohnt sich (1× bauen, mehrfach nutzen).
+
+### 2.3 Markdown-Rendering für Result + Description
+- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml`
+- **Aktuell:** `TextBox IsReadOnly="True"` mit Plaintext.
+- **Soll:** `Markdown.Avalonia` Package einbinden und auf `MarkdownScrollViewer` umstellen.
+- **Aufwand:** mittel — Package + ein paar XAML-Anpassungen. Theme-Integration kann nerven.
+
+### 2.4 Live-Log Auto-Scroll
+- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` (oder im VM)
+- **Aktuell:** ndjson-Zeilen werden angehängt, aber Scrollposition bleibt stehen.
+- **Soll:** Bei jeder neuen Zeile `ScrollViewer.ScrollToEnd()` solange User nicht manuell hochgescrollt hat (Sticky-Bottom-Pattern).
+- **Aufwand:** klein, ein attached behavior reicht.
+
+### 2.5 Diff-Viewer
+- **Datei:** `TaskDetailViewModel.ShowDiffAsync`
+- **Aktuell:** `Process.Start("cmd", "/k git diff …")` — separates Konsolenfenster, hässlich.
+- **Soll:** entweder unified-diff inline anzeigen (`git diff` Output in `TextBox` mit Mono-Font + Color für +/-) oder einen externen Diff-Tool-Hook (`git difftool`).
+- **Aufwand:** mittel. MVP: einfach nur den Diff-Output in einem Modal.
+
+### 2.6 Status-Bar Active-Tasks Live-Update
+- **Datei:** `StatusBarViewModel`
+- **Risiko:** das Slot-State-Update kommt vom WorkerClient, aber `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei `IsConnected`-Wechsel (vom Slice-F-Agent dokumentiert).
+- **Soll:** Über `WeakReferenceMessenger` (CommunityToolkit.Mvvm) eine Connection-Change-Message verteilen, an die alle `TaskItemViewModel` lauschen.
+- **Aufwand:** klein, aber muss sauber gemacht werden.
+
+### 2.7 Settings-Dialog
+- **Datei:** *neu* — `Views/SettingsDialog.axaml` + VM
+- **Aktuell:** `~/.todo-app/ui.config.json` muss von Hand editiert werden.
+- **Soll:** Dialog mit Feldern: DB-Pfad, SignalR-Port, Default-Tags. Persistiert zurück in JSON.
+- **Aufwand:** mittel. Achtung: Port-Wechsel braucht Worker-Restart.
+
+---
+
+## 3. Worker-Robustheit
+
+### 3.1 CLI-Preflight beim Worker-Start
+- **Datei:** `src/ClaudeDo.Worker/Program.cs`
+- **Soll:** vor `app.Run()` `claude --version` ausführen; bei Fehler `app.Logger.LogCritical` + `Environment.Exit(1)`.
+- **Aufwand:** klein, ~20 Zeilen. Liefert Verification Step 2.
+
+### 3.2 Worktree-Cleanup beim Anlege-Failed
+- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
+- **Aktuell:** Wenn `WorktreeAddAsync` zwischen `CreateAsync`-Schritten failt (z.B. Branch existiert schon), bleibt evtl. ein halbangelegter Worktree-Dir auf der Platte.
+- **Soll:** try/finally — bei Fehler `git worktree remove --force` als Best-Effort-Cleanup.
+- **Aufwand:** klein.
+
+### 3.3 Logging über `Microsoft.Extensions.Logging` strukturieren
+- **Datei:** alle Worker-Komponenten
+- **Aktuell:** ILogger wird benutzt, aber kein File-Sink konfiguriert.
+- **Soll:** Optional Serilog oder einfach `AddFile` (Karambolage.Extensions.Logging.File) — Service-Modus braucht persistente Logs außerhalb der Console.
+- **Aufwand:** klein.
+
+### 3.4 Tag-Negation / Exclusion (Plan-TODO)
+- **Plan-Sektion:** "Tag-Modell"
+- **Aktuell:** Tags sind rein additiv (`list_tags ∪ task_tags`).
+- **Soll:** Mechanismus, um auf Task-Ebene einen List-Tag auszuschließen. Z.B. neue Tabelle `task_tag_exclusions` ODER ein Prefix `!tag` im task_tags-Eintrag.
+- **Aufwand:** mittel — Schema + Repo + Tests + UI.
+
+---
+
+## 4. Service-Deployment (Plan-Sektion „Worker als Windows-Service")
+
+### 4.1 Windows-Service-Hosting in Code
+- **Datei:** `src/ClaudeDo.Worker/Program.cs`
+- **Pakete:** `Microsoft.Extensions.Hosting.WindowsServices`
+- **Soll:**
+ ```csharp
+ builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
+ builder.Logging.AddEventLog(...);
+ ```
+- **Aufwand:** klein.
+
+### 4.2 Pfad-Auflösung absolut machen
+- Bereits in `WorkerConfig.Load` per `Paths.Expand` gemacht — verifizieren, dass auch `cfg.ClaudeBin` ggf. in Service-PATH gefunden wird.
+
+### 4.3 Install-Skripte / Doku
+- **Datei:** *neu* — `docs/install-service.md` oder `scripts/install-service.cmd`
+- **Inhalt:** `dotnet publish` + `sc.exe create` + `sc.exe failure` + Hinweis auf `obj=` (User-Account) wegen Claude-CLI-Session.
+- **Aufwand:** klein.
+
+### 4.4 (später) Installer-Projekt
+- WiX/MSIX, registriert Service + UI-Shortcut. Plan-Sektion „Offene Punkte".
+
+---
+
+## 5. Tests / CI
+
+### 5.1 GitHub-Actions / Gitea-Actions Pipeline
+- **Datei:** *neu* — `.gitea/workflows/ci.yml` (oder `.github/workflows/ci.yml`)
+- **Inhalt:** `dotnet restore` → `dotnet build --no-restore` → `dotnet test --no-build`. Auf Push + PR.
+- **Aufwand:** klein.
+
+### 5.2 Echter SignalR-Roundtrip-Test
+- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
+- **Soll:** mit `WebApplicationFactory` + `HubConnectionBuilder` testen, dass `Ping`, `GetActive`, `RunNow`-Throw-Verhalten korrekt sind. Plan-Verification 1b + 9.
+- **Aufwand:** mittel.
+
+### 5.3 Smoke-Test gegen echten `claude`
+- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
+- **Soll:** Real-CLI-Test, der mit `[Fact(Skip="..."]` ausgegraut bleibt und nur lokal aktiviert wird, wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
+- **Aufwand:** klein.
+
+---
+
+## 6. Dokumentation
+
+### 6.1 README.md
+- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config.
+- **Aufwand:** klein.
+
+### 6.2 `docs/architecture.md`
+- In `plan.md` schon teilweise enthalten — kann entweder konsolidiert oder explizit ausgegliedert werden.
+
+### 6.3 ADRs für die getroffenen Entscheidungen
+- Z.B. „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „SignalR über Loopback ohne Auth".
+- **Aufwand:** klein, hilfreich für später.
+
+---
+
+## 7. Bekannte Code-Schulden / Smells
+
+| Stelle | Issue |
+|---|---|
+| `WorkerHub.GetActive` returnt `IReadOnlyList` mit anonymen Typen | Sollte ein expliziter DTO sein (`ActiveTaskDto`), den Worker UND Ui teilen. Aktuell duplizieren beide das Schema. |
+| `TaskRunner` führt eine `if (list.WorkingDir != null)` Verzweigung mitten in der Methode | Strategy-Pattern (`IRunStrategy`: SandboxStrategy, WorktreeStrategy) wenn die Methode wächst. Aktuell noch klein genug. |
+| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern. Toleriert, weil nur in `App.OnFrameworkInitializationCompleted` verwendet. Falls mehr Code drauf zugreift → echtes DI durchziehen. |
+| Embedded `schema.sql` ohne Versionierung | Solange das Schema nicht in Production läuft, OK. Sobald User-Daten existieren → `migrations/` Folder + Version-Tabelle. |
+| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer. |
+
+---
+
+## Empfohlene Reihenfolge für die nächste Session
+
+1. **Verification Step 4** zusammen durchspielen → falls etwas grundlegend kaputt ist, jetzt finden, nicht später.
+2. **CLI-Preflight (3.1)** + **Folder-Picker (2.1)** + **Delete-Confirm (2.2)** — kleine, isolierte Wins.
+3. **Auto-Scroll (2.4)** + **Active-Tasks Live-Update (2.6)** — User-Experience im Detail-Pane.
+4. **Markdown-Rendering (2.3)** — größer, lohnt sich aber für Lesbarkeit.
+5. **Worktree-Cleanup (3.2)** — Robustheit, bevor wir Worktrees ernsthaft nutzen.
+6. **CI-Pipeline (5.1)** — automatisches Sicherheitsnetz für alles weitere.
+7. **Service-Deployment (4)** — wenn die App lokal stabil läuft.
+8. **Settings-Dialog (2.7)** + **Diff-Viewer (2.5)** — Polish.
+9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
+
+Punkte 1–3 sind ein realistischer Block für eine Session.
diff --git a/docs/superpowers/plans/2026-04-14-ui-fixes.md b/docs/superpowers/plans/2026-04-14-ui-fixes.md
new file mode 100644
index 0000000..72179b7
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-14-ui-fixes.md
@@ -0,0 +1,1550 @@
+# UI Fixes 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:** Fix four post-integration issues: raw NDJSON display, missing start feedback, lost live output, and missing config editors + modal theming.
+
+**Architecture:** A new `StreamLineFormatter` in the UI layer parses NDJSON for display. `TaskDetailViewModel` switches from `ObservableCollection` to a single `string` property. Optimistic UI feedback via a local `RunNowRequestedEvent`. Existing editor dialogs get config sections and proper theming.
+
+**Tech Stack:** .NET 8, Avalonia 12 (Fluent dark theme), CommunityToolkit.Mvvm, System.Text.Json, xUnit
+
+---
+
+## File Structure
+
+### New Files
+- `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` — NDJSON-to-text parser for UI display
+- `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` — test project for UI helpers
+- `tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs` — formatter unit tests
+
+### Modified Files
+- `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — LiveText, formatter, start feedback, log reload
+- `src/ClaudeDo.Ui/Views/TaskDetailView.axaml` — TextBox replaces ItemsControl, auto-scroll
+- `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` — auto-scroll handler
+- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — RunNowRequestedEvent, GetAgentsAsync, AgentInfo DTO
+- `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — IsStarting property
+- `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — wire RunNowRequestedEvent
+- `src/ClaudeDo.Ui/Views/TaskListView.axaml` — starting state visual
+- `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` — config fields, agent loading
+- `src/ClaudeDo.Ui/Views/ListEditorView.axaml` — config section, theming
+- `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — config override fields
+- `src/ClaudeDo.Ui/Views/TaskEditorView.axaml` — config section, theming
+- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — default model fallback
+- `ClaudeDo.slnx` — add new test project
+
+---
+
+### Task 1: Create UI test project
+
+**Files:**
+- Create: `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
+
+- [ ] **Step 1: Create the test project**
+
+```xml
+
+
+
+ net8.0
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add to solution**
+
+Run: `dotnet sln ClaudeDo.slnx add tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
+Expected: Project added successfully.
+
+- [ ] **Step 3: Verify build**
+
+Run: `dotnet build tests/ClaudeDo.Ui.Tests`
+Expected: Build succeeded.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj ClaudeDo.slnx
+git commit -m "chore(tests): add ClaudeDo.Ui.Tests project"
+```
+
+---
+
+### Task 2: StreamLineFormatter — text deltas (TDD)
+
+**Files:**
+- Create: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
+- Create: `tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs`
+
+- [ ] **Step 1: Write failing tests for text delta extraction**
+
+```csharp
+// tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
+using ClaudeDo.Ui.Helpers;
+
+namespace ClaudeDo.Ui.Tests.Helpers;
+
+public class StreamLineFormatterTests
+{
+ private readonly StreamLineFormatter _sut = new();
+
+ [Fact]
+ public void FormatLine_TextDelta_ReturnsTextContent()
+ {
+ var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello world"}}}""";
+ var result = _sut.FormatLine(line);
+ Assert.Equal("Hello world", result);
+ }
+
+ [Fact]
+ public void FormatLine_ConsecutiveTextDeltas_ReturnEachDelta()
+ {
+ var line1 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello "}}}""";
+ var line2 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}""";
+ Assert.Equal("Hello ", _sut.FormatLine(line1));
+ Assert.Equal("world", _sut.FormatLine(line2));
+ }
+
+ [Fact]
+ public void FormatLine_ContentBlockStop_ReturnsNewline()
+ {
+ var delta = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}}""";
+ var stop = """{"type":"stream_event","event":{"type":"content_block_stop","index":0}}""";
+ _sut.FormatLine(delta);
+ Assert.Equal("\n", _sut.FormatLine(stop));
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet`
+Expected: Build error — `StreamLineFormatter` does not exist.
+
+- [ ] **Step 3: Write minimal StreamLineFormatter**
+
+```csharp
+// src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
+using System.Text.Json;
+
+namespace ClaudeDo.Ui.Helpers;
+
+public sealed class StreamLineFormatter
+{
+ public string? FormatLine(string ndjsonLine)
+ {
+ if (string.IsNullOrWhiteSpace(ndjsonLine)) return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(ndjsonLine);
+ var root = doc.RootElement;
+
+ if (!root.TryGetProperty("type", out var typeProp)) return null;
+ var type = typeProp.GetString();
+
+ return type switch
+ {
+ "stream_event" => HandleStreamEvent(root),
+ _ => null,
+ };
+ }
+ catch (JsonException)
+ {
+ return ndjsonLine; // Fallback: show raw line
+ }
+ }
+
+ private static string? HandleStreamEvent(JsonElement root)
+ {
+ if (!root.TryGetProperty("event", out var evt)) return null;
+ if (!evt.TryGetProperty("type", out var evtTypeProp)) return null;
+ var evtType = evtTypeProp.GetString();
+
+ return evtType switch
+ {
+ "content_block_delta" => HandleDelta(evt),
+ "content_block_stop" => "\n",
+ _ => null,
+ };
+ }
+
+ private static string? HandleDelta(JsonElement evt)
+ {
+ if (!evt.TryGetProperty("delta", out var delta)) return null;
+ if (!delta.TryGetProperty("type", out var deltaType)) return null;
+
+ return deltaType.GetString() switch
+ {
+ "text_delta" => delta.TryGetProperty("text", out var text) ? text.GetString() : null,
+ _ => null, // input_json_delta etc. — skip
+ };
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet`
+Expected: 3 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
+git commit -m "feat(ui): add StreamLineFormatter with text delta parsing (TDD)"
+```
+
+---
+
+### Task 3: StreamLineFormatter — tool use, result, system, fallback (TDD)
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
+- Modify: `tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs`
+
+- [ ] **Step 1: Write failing tests for remaining event types**
+
+Append to `StreamLineFormatterTests.cs`:
+
+```csharp
+[Fact]
+public void FormatLine_ToolUseStart_ReturnsToolNameLine()
+{
+ var line = """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_xxx","name":"Read","input":{}}}}""";
+ var result = _sut.FormatLine(line);
+ Assert.Equal("\n[Tool: Read]\n", result);
+}
+
+[Fact]
+public void FormatLine_InputJsonDelta_ReturnsNull()
+{
+ var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"file\":"}}}""";
+ Assert.Null(_sut.FormatLine(line));
+}
+
+[Fact]
+public void FormatLine_Result_ReturnsFormattedResult()
+{
+ var line = """{"type":"result","result":"Task completed successfully.","session_id":"sess_123"}""";
+ var result = _sut.FormatLine(line);
+ Assert.Equal("\n--- Result ---\nTask completed successfully.\n", result);
+}
+
+[Fact]
+public void FormatLine_ApiRetry_ReturnsRetryNotice()
+{
+ var line = """{"type":"system","subtype":"api_retry","message":"Retrying..."}""";
+ var result = _sut.FormatLine(line);
+ Assert.Equal("\n[Retrying API call...]\n", result);
+}
+
+[Fact]
+public void FormatLine_SystemNonRetry_ReturnsNull()
+{
+ var line = """{"type":"system","subtype":"init"}""";
+ Assert.Null(_sut.FormatLine(line));
+}
+
+[Fact]
+public void FormatLine_AssistantType_ReturnsNull()
+{
+ var line = """{"type":"assistant","message":{"role":"assistant","content":[]}}""";
+ Assert.Null(_sut.FormatLine(line));
+}
+
+[Fact]
+public void FormatLine_MalformedJson_ReturnsRawLine()
+{
+ var line = "this is not json";
+ Assert.Equal("this is not json", _sut.FormatLine(line));
+}
+
+[Fact]
+public void FormatLine_MessageStartAndDelta_ReturnsNull()
+{
+ var start = """{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_xxx"}}}""";
+ var delta = """{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":50}}}""";
+ Assert.Null(_sut.FormatLine(start));
+ Assert.Null(_sut.FormatLine(delta));
+}
+```
+
+- [ ] **Step 2: Run tests to verify new tests fail**
+
+Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet`
+Expected: Several failures — `FormatLine_ToolUseStart`, `FormatLine_Result`, `FormatLine_ApiRetry` return null instead of expected strings.
+
+- [ ] **Step 3: Extend StreamLineFormatter to handle all event types**
+
+Update the `FormatLine` method's switch expression in `StreamLineFormatter.cs`:
+
+```csharp
+return type switch
+{
+ "stream_event" => HandleStreamEvent(root),
+ "result" => HandleResult(root),
+ "system" => HandleSystem(root),
+ "assistant" => null,
+ _ => null,
+};
+```
+
+Add `HandleStreamEvent` case for `content_block_start`:
+
+```csharp
+private static string? HandleStreamEvent(JsonElement root)
+{
+ if (!root.TryGetProperty("event", out var evt)) return null;
+ if (!evt.TryGetProperty("type", out var evtTypeProp)) return null;
+ var evtType = evtTypeProp.GetString();
+
+ return evtType switch
+ {
+ "content_block_start" => HandleBlockStart(evt),
+ "content_block_delta" => HandleDelta(evt),
+ "content_block_stop" => "\n",
+ _ => null,
+ };
+}
+```
+
+Add new methods:
+
+```csharp
+private static string? HandleBlockStart(JsonElement evt)
+{
+ if (!evt.TryGetProperty("content_block", out var block)) return null;
+ if (!block.TryGetProperty("type", out var blockType)) return null;
+
+ if (blockType.GetString() == "tool_use" &&
+ block.TryGetProperty("name", out var name))
+ {
+ return $"\n[Tool: {name.GetString()}]\n";
+ }
+ return null;
+}
+
+private static string? HandleResult(JsonElement root)
+{
+ if (root.TryGetProperty("result", out var resultProp))
+ {
+ var text = resultProp.GetString();
+ if (text is not null)
+ return $"\n--- Result ---\n{text}\n";
+ }
+ return null;
+}
+
+private static string? HandleSystem(JsonElement root)
+{
+ if (root.TryGetProperty("subtype", out var subtype) &&
+ subtype.GetString() == "api_retry")
+ {
+ return "\n[Retrying API call...]\n";
+ }
+ return null;
+}
+```
+
+- [ ] **Step 4: Run tests to verify all pass**
+
+Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet`
+Expected: 11 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
+git commit -m "feat(ui): complete StreamLineFormatter with tool use, result, system events"
+```
+
+---
+
+### Task 4: StreamLineFormatter — FormatFile method (TDD)
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
+- Modify: `tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs`
+
+- [ ] **Step 1: Write failing test for FormatFile**
+
+Append to `StreamLineFormatterTests.cs`:
+
+```csharp
+[Fact]
+public void FormatFile_ParsesAllLinesAndReturnsFormattedText()
+{
+ var dir = Path.Combine(Path.GetTempPath(), "claudedo_test_" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(dir);
+ var filePath = Path.Combine(dir, "test.ndjson");
+ try
+ {
+ var lines = new[]
+ {
+ """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}""",
+ """{"type":"stream_event","event":{"type":"content_block_stop","index":0}}""",
+ """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t1","name":"Edit","input":{}}}}""",
+ """{"type":"stream_event","event":{"type":"content_block_stop","index":1}}""",
+ """{"type":"result","result":"Done.","session_id":"s1"}""",
+ };
+ File.WriteAllLines(filePath, lines);
+
+ var result = _sut.FormatFile(filePath);
+ Assert.Contains("Hello", result);
+ Assert.Contains("[Tool: Edit]", result);
+ Assert.Contains("--- Result ---", result);
+ Assert.Contains("Done.", result);
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+}
+
+[Fact]
+public void FormatFile_TrimsLargeContent()
+{
+ var dir = Path.Combine(Path.GetTempPath(), "claudedo_test_" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(dir);
+ var filePath = Path.Combine(dir, "large.ndjson");
+ try
+ {
+ // Generate enough text deltas to exceed 50k chars
+ var lines = new List();
+ for (int i = 0; i < 600; i++)
+ {
+ var chunk = new string('x', 100);
+ lines.Add($$"""{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"{{chunk}}\n"}}}""");
+ }
+ File.WriteAllLines(filePath, lines);
+
+ var result = _sut.FormatFile(filePath);
+ Assert.True(result.Length <= 50_000 + 200); // some tolerance for trimming at newline boundary
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter "FormatFile" -v quiet`
+Expected: Build error — `FormatFile` method does not exist.
+
+- [ ] **Step 3: Implement FormatFile and trimming**
+
+Add to `StreamLineFormatter.cs`:
+
+```csharp
+private const int MaxLength = 50_000;
+
+public string FormatFile(string filePath)
+{
+ var sb = new System.Text.StringBuilder();
+ foreach (var line in File.ReadLines(filePath))
+ {
+ var formatted = FormatLine(line);
+ if (formatted is not null)
+ sb.Append(formatted);
+ }
+ return Trim(sb.ToString());
+}
+
+public static string Trim(string text)
+{
+ if (text.Length <= MaxLength) return text;
+ var trimStart = text.Length - MaxLength;
+ var newlineAfter = text.IndexOf('\n', trimStart);
+ if (newlineAfter >= 0 && newlineAfter < trimStart + 200)
+ trimStart = newlineAfter + 1;
+ return text[trimStart..];
+}
+```
+
+Add `using System.Text;` to the top of the file.
+
+- [ ] **Step 4: Run tests to verify all pass**
+
+Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet`
+Expected: 13 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
+git commit -m "feat(ui): add FormatFile and text trimming to StreamLineFormatter"
+```
+
+---
+
+### Task 5: TaskDetailViewModel — replace LiveLines with LiveText
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs`
+
+- [ ] **Step 1: Replace LiveLines with LiveText and wire formatter**
+
+In `TaskDetailViewModel.cs`, make these changes:
+
+1. Add using at top:
+```csharp
+using ClaudeDo.Ui.Helpers;
+```
+
+2. Replace the LiveLines property (line 40) and MaxLiveLines constant (line 47):
+
+Remove:
+```csharp
+public ObservableCollection LiveLines { get; } = new();
+```
+and:
+```csharp
+private const int MaxLiveLines = 500;
+```
+
+Add:
+```csharp
+[ObservableProperty] private string _liveText = "";
+private StreamLineFormatter _formatter = new();
+```
+
+3. Update `LoadAsync` (line 69) — replace `LiveLines.Clear()` with:
+```csharp
+LiveText = "";
+_formatter = new StreamLineFormatter();
+```
+
+4. Update `Clear` method — replace `LiveLines.Clear()` with:
+```csharp
+LiveText = "";
+_formatter = new StreamLineFormatter();
+```
+
+5. Update `OnTaskMessage` (lines 259-265):
+
+Replace entire method:
+```csharp
+private void OnTaskMessage(string taskId, string line)
+{
+ if (taskId != _taskId) return;
+ var formatted = _formatter.FormatLine(line);
+ if (formatted is not null)
+ {
+ LiveText += formatted;
+ if (LiveText.Length > 50_000)
+ LiveText = StreamLineFormatter.Trim(LiveText);
+ }
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
+git commit -m "refactor(ui): replace LiveLines with LiveText + StreamLineFormatter"
+```
+
+---
+
+### Task 6: TaskDetailView — TextBox replaces ItemsControl + auto-scroll
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Views/TaskDetailView.axaml`
+- Modify: `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs`
+
+- [ ] **Step 1: Replace ItemsControl with TextBox in TaskDetailView.axaml**
+
+Replace lines 107-122 (the "Live Output" section heading through the Border/ItemsControl):
+
+Old:
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+New:
+```xml
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add auto-scroll in code-behind**
+
+In `TaskDetailView.axaml.cs`, add an `OnDataContextChanged` override and property-change handler:
+
+Add using:
+```csharp
+using System.ComponentModel;
+```
+
+Add after the `FocusTitle` method:
+
+```csharp
+protected override void OnDataContextChanged(EventArgs e)
+{
+ base.OnDataContextChanged(e);
+ if (DataContext is TaskDetailViewModel vm)
+ {
+ vm.PropertyChanged += OnViewModelPropertyChanged;
+ }
+}
+
+private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+{
+ if (e.PropertyName == nameof(TaskDetailViewModel.LiveText))
+ {
+ var scroll = this.FindControl("LiveOutputScroll");
+ scroll?.ScrollToEnd();
+ }
+}
+```
+
+- [ ] **Step 3: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Views/TaskDetailView.axaml src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs
+git commit -m "feat(ui): replace ItemsControl with TextBox for formatted live output"
+```
+
+---
+
+### Task 7: Log reload from disk
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs`
+
+- [ ] **Step 1: Add log reload to LoadAsync**
+
+In `TaskDetailViewModel.cs`, in `LoadAsync`, after `LogPath = task.LogPath;` (around line 81), add:
+
+```csharp
+// Load historical log for completed tasks
+if (task.LogPath is not null
+ && task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
+ && File.Exists(task.LogPath))
+{
+ _formatter = new StreamLineFormatter();
+ LiveText = _formatter.FormatFile(task.LogPath);
+}
+```
+
+Add `using System.IO;` at the top if not already present.
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
+git commit -m "feat(ui): reload formatted log from disk for completed tasks"
+```
+
+---
+
+### Task 8: WorkerClient — RunNowRequestedEvent
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
+
+- [ ] **Step 1: Add RunNowRequestedEvent and fire it in RunNowAsync**
+
+In `WorkerClient.cs`:
+
+Add event declaration after the existing events (after line 44):
+```csharp
+public event Action? RunNowRequestedEvent;
+```
+
+Update `RunNowAsync` method (lines 163-166):
+```csharp
+public async Task RunNowAsync(string taskId)
+{
+ RunNowRequestedEvent?.Invoke(taskId);
+ await _hub.InvokeAsync("RunNow", taskId);
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Services/WorkerClient.cs
+git commit -m "feat(ui): add RunNowRequestedEvent for optimistic UI feedback"
+```
+
+---
+
+### Task 9: TaskItemViewModel — IsStarting state
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs`
+
+- [ ] **Step 1: Add IsStarting property and update CanRunNow**
+
+In `TaskItemViewModel.cs`:
+
+Add property after line 16:
+```csharp
+[ObservableProperty] private bool _isStarting;
+```
+
+Update `CanRunNow` (line 83-84):
+```csharp
+private bool CanRunNow() =>
+ _canRunNow() && Status != TaskStatus.Running && !IsStarting;
+```
+
+Add method to set starting state (after `Refresh` method):
+```csharp
+public void SetStarting()
+{
+ IsStarting = true;
+ StatusText = "starting...";
+ RunNowCommand.NotifyCanExecuteChanged();
+}
+
+public void ClearStarting()
+{
+ IsStarting = false;
+ RunNowCommand.NotifyCanExecuteChanged();
+}
+```
+
+Update `Refresh` method — add after `OnPropertyChanged(nameof(IsRunning))` (line 68):
+```csharp
+IsStarting = false;
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs
+git commit -m "feat(ui): add IsStarting state to TaskItemViewModel"
+```
+
+---
+
+### Task 10: TaskListViewModel — wire RunNowRequested to TaskItemViewModels
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs`
+
+- [ ] **Step 1: Subscribe to RunNowRequestedEvent and TaskStartedEvent**
+
+In `TaskListViewModel.cs` constructor, after the existing `worker.PropertyChanged` subscription (after line 57):
+
+```csharp
+worker.RunNowRequestedEvent += taskId =>
+{
+ var item = Tasks.FirstOrDefault(t => t.Id == taskId);
+ item?.SetStarting();
+};
+
+worker.TaskStartedEvent += (_, taskId, _) =>
+{
+ var item = Tasks.FirstOrDefault(t => t.Id == taskId);
+ item?.ClearStarting();
+};
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
+git commit -m "feat(ui): wire RunNowRequested to TaskItemViewModel starting state"
+```
+
+---
+
+### Task 11: TaskDetailViewModel — start feedback
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs`
+
+- [ ] **Step 1: Subscribe to RunNowRequestedEvent and TaskStartedEvent**
+
+In `TaskDetailViewModel.cs` constructor, after `worker.TaskUpdatedEvent += OnTaskUpdated;` (line 63):
+
+```csharp
+worker.RunNowRequestedEvent += OnRunNowRequested;
+worker.TaskStartedEvent += OnTaskStarted;
+```
+
+Add the handler methods before `OnTaskMessage`:
+
+```csharp
+private void OnRunNowRequested(string taskId)
+{
+ if (taskId != _taskId) return;
+ StatusText = "starting...";
+ LiveText = "";
+ _formatter = new StreamLineFormatter();
+}
+
+private void OnTaskStarted(string slot, string taskId, DateTime startedAt)
+{
+ if (taskId != _taskId) return;
+ StatusText = "running";
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
+git commit -m "feat(ui): add optimistic start feedback to TaskDetailViewModel"
+```
+
+---
+
+### Task 12: TaskListView — starting state visual
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Views/TaskListView.axaml`
+
+- [ ] **Step 1: Add starting indicator next to running indicator**
+
+In `TaskListView.axaml`, find the running indicator Ellipse (the orange dot visible when `IsRunning`). After it, add a similar indicator for the starting state. The exact location depends on the layout, but it should be adjacent to the existing status indicators.
+
+Find the `IsRunning` Ellipse and add a sibling for `IsStarting`:
+
+```xml
+
+
+```
+
+Use a gold/yellow color (`#FFD700`) to distinguish "starting" from "running" (orange).
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Views/TaskListView.axaml
+git commit -m "feat(ui): add starting state indicator in task list"
+```
+
+---
+
+### Task 13: Modal theming — ListEditorView
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Views/ListEditorView.axaml`
+
+- [ ] **Step 1: Apply theme resources to ListEditorView**
+
+In `ListEditorView.axaml`, update the `` element — add `Background`:
+
+```xml
+
+```
+
+Add `Foreground="{StaticResource TextSecondaryBrush}"` to each label TextBlock ("Name", "Working Directory", "Default Commit Type").
+
+Style the Save button with accent:
+```xml
+
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Views/ListEditorView.axaml
+git commit -m "fix(ui): apply dark theme to ListEditorView modal"
+```
+
+---
+
+### Task 14: Modal theming — TaskEditorView
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Views/TaskEditorView.axaml`
+
+- [ ] **Step 1: Apply theme resources to TaskEditorView**
+
+In `TaskEditorView.axaml`, update the `` element — add `Background`:
+
+```xml
+
+```
+
+Add `Foreground="{StaticResource TextSecondaryBrush}"` to each label TextBlock ("Title", "Description", "Status", "Commit Type", "Tags (comma-separated)").
+
+Style the Save button:
+```xml
+
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Views/TaskEditorView.axaml
+git commit -m "fix(ui): apply dark theme to TaskEditorView modal"
+```
+
+---
+
+### Task 15: WorkerClient — GetAgentsAsync + AgentInfo DTO
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
+
+- [ ] **Step 1: Add AgentInfo DTO and hub methods**
+
+In `WorkerClient.cs`:
+
+Add the DTO record at the bottom of the file (outside the `WorkerClient` class, inside the namespace):
+
+```csharp
+public record AgentInfo(string Name, string Description, string Path);
+```
+
+Add methods after `WakeQueueAsync` (after line 175):
+
+```csharp
+public async Task> GetAgentsAsync()
+{
+ try
+ {
+ var agents = await _hub.InvokeAsync>("GetAgents");
+ return agents ?? [];
+ }
+ catch
+ {
+ return [];
+ }
+}
+
+public async Task RefreshAgentsAsync()
+{
+ await _hub.InvokeAsync("RefreshAgents");
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Services/WorkerClient.cs
+git commit -m "feat(ui): add GetAgentsAsync and AgentInfo DTO to WorkerClient"
+```
+
+---
+
+### Task 16: ListEditorViewModel — config fields
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs`
+
+- [ ] **Step 1: Add config properties and model mapping**
+
+In `ListEditorViewModel.cs`:
+
+Add using:
+```csharp
+using ClaudeDo.Ui.Services;
+```
+
+Add static model mapping after `CommitTypes`:
+
+```csharp
+public static string[] ModelDisplayNames { get; } = ["Sonnet", "Opus", "Haiku"];
+
+private static readonly Dictionary ModelToId = new()
+{
+ ["Sonnet"] = "claude-sonnet-4-6",
+ ["Opus"] = "claude-opus-4-6",
+ ["Haiku"] = "claude-haiku-4-5",
+};
+
+private static readonly Dictionary IdToModel =
+ ModelToId.ToDictionary(kv => kv.Value, kv => kv.Key);
+
+public static string ModelIdToDisplay(string? modelId) =>
+ modelId is not null && IdToModel.TryGetValue(modelId, out var display) ? display : "Sonnet";
+
+public static string? ModelDisplayToId(string display) =>
+ ModelToId.TryGetValue(display, out var id) ? id : null;
+```
+
+Add config properties after `_windowTitle`:
+
+```csharp
+[ObservableProperty] private string _model = "Sonnet";
+[ObservableProperty] private string? _systemPrompt;
+[ObservableProperty] private AgentInfo? _selectedAgent;
+
+public List AvailableAgents { get; set; } = [];
+```
+
+Add a `WorkerClient` field and update constructor — but since the ViewModel is created via factory (`Func`), the WorkerClient needs to be injected. Add a method to load agents:
+
+```csharp
+public async Task LoadAgentsAsync(WorkerClient worker)
+{
+ AvailableAgents = await worker.GetAgentsAsync();
+}
+```
+
+Update `InitForEdit` to accept config:
+
+```csharp
+public void InitForEdit(ListEntity entity, Data.Models.ListConfigEntity? config)
+{
+ _editId = entity.Id;
+ _createdAt = entity.CreatedAt;
+ Name = entity.Name;
+ WorkingDir = entity.WorkingDir;
+ DefaultCommitType = entity.DefaultCommitType;
+ WindowTitle = $"Edit List: {entity.Name}";
+
+ if (config is not null)
+ {
+ Model = ModelIdToDisplay(config.Model);
+ SystemPrompt = config.SystemPrompt;
+ SelectedAgentPath = config.AgentPath;
+ }
+}
+```
+
+Add a method to build the config entity for saving:
+
+```csharp
+public Data.Models.ListConfigEntity? BuildConfig(string listId)
+{
+ var modelId = ModelDisplayToId(Model);
+ if (modelId is null && SystemPrompt is null && SelectedAgentPath is null)
+ return null;
+
+ return new Data.Models.ListConfigEntity
+ {
+ ListId = listId,
+ Model = modelId,
+ SystemPrompt = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt.Trim(),
+ AgentPath = SelectedAgentPath,
+ };
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
+git commit -m "feat(ui): add model/prompt/agent config fields to ListEditorViewModel"
+```
+
+---
+
+### Task 17: ListEditorView — config section XAML
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Views/ListEditorView.axaml`
+
+- [ ] **Step 1: Add config section and increase window height**
+
+In `ListEditorView.axaml`, update `Height="280"` to `Height="480"`.
+
+Before the Save/Cancel button StackPanel, add a config section:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Note: The agent ComboBox will need to be populated from code-behind or via `ItemsSource` binding to `AvailableAgents`. Since `AvailableAgents` is a `List`, bind it:
+
+```xml
+
+
+
+
+
+
+
+```
+
+However, since `SelectedValue` binding with a path property requires `SelectedValueBinding`, and Avalonia's ComboBox support differs from WPF, the simpler approach is to bind to a list of agent path strings with display names handled in the ViewModel. Use a list of display strings and map in the VM.
+
+Simpler approach — replace `AvailableAgents` with two parallel properties in the VM:
+
+Actually, the cleanest Avalonia pattern: use `ItemsSource` and handle selection via `SelectedItem` where items are `AgentInfo` records, and map to path in the VM.
+
+Update the ComboBox to:
+```xml
+
+
+
+
+
+
+
+```
+
+This requires adding `xmlns:svc="using:ClaudeDo.Ui.Services"` to the Window element.
+
+And in the ViewModel, change `SelectedAgentPath` to `SelectedAgent`:
+
+```csharp
+[ObservableProperty] private AgentInfo? _selectedAgent;
+```
+
+Map to/from path in `InitForEdit` and `BuildConfig`:
+- `InitForEdit`: `SelectedAgent = AvailableAgents.FirstOrDefault(a => a.Path == config?.AgentPath);`
+- `BuildConfig`: use `SelectedAgent?.Path`
+
+Update `ListEditorViewModel` to use `SelectedAgent` instead of `SelectedAgentPath` in `BuildConfig`:
+```csharp
+AgentPath = SelectedAgent?.Path,
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Views/ListEditorView.axaml src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
+git commit -m "feat(ui): add config section to ListEditorView"
+```
+
+---
+
+### Task 18: Wire ListEditor config loading/saving in MainWindowViewModel
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs` (or wherever list editor is opened)
+
+The list editor is opened from `MainWindowViewModel` (or wherever the "Edit List" action lives). Find where `ListEditorViewModel.InitForEdit` is called and update it to:
+
+1. Load config before opening: `var config = await _listRepo.GetConfigAsync(entity.Id);`
+2. Load agents: `await editor.LoadAgentsAsync(_worker);`
+3. Call updated `InitForEdit(entity, config)`
+4. After save, persist config:
+```csharp
+var configEntity = editor.BuildConfig(saved.Id);
+if (configEntity is not null)
+ await _listRepo.SetConfigAsync(configEntity);
+```
+
+- [ ] **Step 1: Find and update list editor call sites**
+
+Search for `InitForEdit` and `InitForCreate` calls for `ListEditorViewModel` in the codebase. Update each call site to load config and agents.
+
+For `InitForCreate`: agents still need to be loaded. Add `await editor.LoadAgentsAsync(_worker);` after `InitForCreate()`.
+
+For `InitForEdit`: add config loading before init:
+```csharp
+var config = await _listRepo.GetConfigAsync(entity.Id);
+await editor.LoadAgentsAsync(_worker);
+editor.InitForEdit(entity, config);
+```
+
+After save, persist config:
+```csharp
+var configEntity = editor.BuildConfig(saved.Id);
+if (configEntity is not null)
+ await _listRepo.SetConfigAsync(configEntity);
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs
+git commit -m "feat(ui): wire config loading/saving in list editor flow"
+```
+
+---
+
+### Task 19: TaskEditorViewModel — config override fields
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs`
+
+- [ ] **Step 1: Add config properties with inheritance**
+
+In `TaskEditorViewModel.cs`:
+
+Add using:
+```csharp
+using ClaudeDo.Ui.Services;
+```
+
+Add static model choices (reuse the mapping from ListEditorViewModel):
+
+```csharp
+public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
+```
+
+Add config properties after `_windowTitle`:
+
+```csharp
+[ObservableProperty] private string _modelChoice = "(list default)";
+[ObservableProperty] private string? _systemPromptOverride;
+[ObservableProperty] private AgentInfo? _selectedAgent;
+public List AvailableAgents { get; set; } = [];
+```
+
+Add agent loading method:
+
+```csharp
+public async Task LoadAgentsAsync(WorkerClient worker)
+{
+ AvailableAgents = await worker.GetAgentsAsync();
+}
+```
+
+Update `InitForEdit` to load config overrides (add after `TagsInput = ...` line):
+
+```csharp
+ModelChoice = entity.Model is not null
+ ? ListEditorViewModel.ModelIdToDisplay(entity.Model)
+ : "(list default)";
+SystemPromptOverride = entity.SystemPrompt;
+SelectedAgent = entity.AgentPath is not null
+ ? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath)
+ : null;
+```
+
+Update `Save` to include config on the entity — in the `Save` method, after building the entity, add:
+
+```csharp
+entity.Model = ModelChoice != "(list default)"
+ ? ListEditorViewModel.ModelDisplayToId(ModelChoice)
+ : null;
+entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
+entity.AgentPath = SelectedAgent?.Path;
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs
+git commit -m "feat(ui): add model/prompt/agent override fields to TaskEditorViewModel"
+```
+
+---
+
+### Task 20: TaskEditorView — config section XAML
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/Views/TaskEditorView.axaml`
+
+- [ ] **Step 1: Add config section and increase height**
+
+In `TaskEditorView.axaml`, update `Height="420"` to `Height="600"`.
+
+Add `xmlns:svc="using:ClaudeDo.Ui.Services"` to the Window element.
+
+Before the Save/Cancel button StackPanel, add:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Views/TaskEditorView.axaml
+git commit -m "feat(ui): add config override section to TaskEditorView"
+```
+
+---
+
+### Task 21: Wire TaskEditor agents loading
+
+**Files:**
+- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs`
+
+- [ ] **Step 1: Load agents in AddTask and EditTask**
+
+In `TaskListViewModel.cs`:
+
+In `AddTask` method (line 130), after `editor.InitForCreate(CurrentListId, defaultCommitType);`, add:
+```csharp
+await editor.LoadAgentsAsync(_worker);
+```
+
+In `EditTask` method (line 173), after `editor.InitForEdit(entity, taskTags);`, add:
+```csharp
+await editor.LoadAgentsAsync(_worker);
+```
+
+Note: `LoadAgentsAsync` must be called BEFORE `InitForEdit` for the task editor so that `AvailableAgents` is populated when `InitForEdit` tries to find the matching `SelectedAgent`. Reorder:
+
+```csharp
+var editor = _editorFactory();
+await editor.LoadAgentsAsync(_worker);
+editor.InitForEdit(entity, taskTags);
+```
+
+Same for `AddTask`:
+```csharp
+var editor = _editorFactory();
+await editor.LoadAgentsAsync(_worker);
+editor.InitForCreate(CurrentListId, defaultCommitType);
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Ui`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
+git commit -m "feat(ui): load available agents when opening task editor"
+```
+
+---
+
+### Task 22: TaskRunner — default model fallback
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
+
+- [ ] **Step 1: Add Sonnet as default model in config resolution**
+
+In `TaskRunner.cs`, find the config resolution (around line 82-83):
+
+```csharp
+var resolvedConfig = new ClaudeRunConfig(
+ Model: task.Model ?? listConfig?.Model,
+```
+
+Change to:
+
+```csharp
+var resolvedConfig = new ClaudeRunConfig(
+ Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/ClaudeDo.Worker`
+Expected: Build succeeded.
+
+- [ ] **Step 3: Run existing Worker tests**
+
+Run: `dotnet test tests/ClaudeDo.Worker.Tests -v quiet`
+Expected: All tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
+git commit -m "feat(worker): default to claude-sonnet-4-6 when no model configured"
+```
+
+---
+
+### Task 23: Full build and test verification
+
+- [ ] **Step 1: Build entire solution**
+
+Run: `dotnet build ClaudeDo.slnx`
+Expected: Build succeeded with 0 errors.
+
+- [ ] **Step 2: Run all tests**
+
+Run: `dotnet test ClaudeDo.slnx -v quiet`
+Expected: All tests pass (existing Worker tests + new Ui tests).
+
+- [ ] **Step 3: Verify no compiler warnings related to changes**
+
+Run: `dotnet build ClaudeDo.slnx -warnaserror`
+Expected: Build succeeded (or only pre-existing warnings unrelated to our changes).
diff --git a/docs/superpowers/plans/2026-04-14-worker-cli-modernization.md b/docs/superpowers/plans/2026-04-14-worker-cli-modernization.md
new file mode 100644
index 0000000..5e07e84
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-14-worker-cli-modernization.md
@@ -0,0 +1,2311 @@
+# Worker CLI Modernization 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:** Modernize the Worker's Claude CLI integration with per-list/task configuration, execution history tracking, multi-turn support, auto-retry, structured output, and agent file management.
+
+**Architecture:** Add `list_config` and `task_runs` tables to the existing SQLite schema. Extract CLI argument building and stream parsing into dedicated classes. TaskRunner gains retry/continue logic. Agent `.md` files live on the filesystem at `~/.todo-app/agents/`, discoverable via SignalR.
+
+**Tech Stack:** .NET 8.0, SQLite (raw ADO.NET), SignalR, xUnit
+
+---
+
+### Task 1: Schema — Add list_config and task_runs tables
+
+**Files:**
+- Modify: `schema/schema.sql:49-62`
+- Modify: `src/ClaudeDo.Data/SchemaInitializer.cs`
+
+This task adds the new tables and columns. Since SQLite doesn't support `ALTER TABLE ADD COLUMN IF NOT EXISTS`, the new columns on `tasks` need a try/catch approach in SchemaInitializer.
+
+- [ ] **Step 1: Add list_config table to schema.sql**
+
+Append before the worktrees table:
+
+```sql
+CREATE TABLE IF NOT EXISTS list_config (
+ list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
+ model TEXT NULL,
+ system_prompt TEXT NULL,
+ agent_path TEXT NULL
+);
+```
+
+- [ ] **Step 2: Add task_runs table to schema.sql**
+
+Append after worktrees table:
+
+```sql
+CREATE TABLE IF NOT EXISTS task_runs (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ run_number INTEGER NOT NULL,
+ session_id TEXT NULL,
+ is_retry INTEGER NOT NULL DEFAULT 0,
+ prompt TEXT NOT NULL,
+ result_markdown TEXT NULL,
+ structured_output TEXT NULL,
+ error_markdown TEXT NULL,
+ exit_code INTEGER NULL,
+ turn_count INTEGER NULL,
+ tokens_in INTEGER NULL,
+ tokens_out INTEGER NULL,
+ log_path TEXT NULL,
+ started_at TIMESTAMP NULL,
+ finished_at TIMESTAMP NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
+```
+
+- [ ] **Step 3: Add migration columns to tasks via SchemaInitializer**
+
+In `SchemaInitializer.cs`, after applying the main schema, add a new `ApplyMigrations` method that runs `ALTER TABLE` in try/catch blocks (SQLite throws "duplicate column name" if the column already exists):
+
+```csharp
+private static void ApplyMigrations(SqliteConnection conn)
+{
+ string[] alterStatements =
+ [
+ "ALTER TABLE tasks ADD COLUMN model TEXT NULL",
+ "ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
+ "ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
+ ];
+
+ foreach (var sql in alterStatements)
+ {
+ try
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.ExecuteNonQuery();
+ }
+ catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode == 1)
+ {
+ // Column already exists — safe to ignore.
+ }
+ }
+}
+```
+
+Call `ApplyMigrations(conn)` right after the main schema `ExecuteNonQuery` in the `ApplyTo` method.
+
+- [ ] **Step 4: Verify schema applies cleanly**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Repository" -v minimal
+```
+
+Expected: all existing repository tests pass (schema changes are additive).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add schema/schema.sql src/ClaudeDo.Data/SchemaInitializer.cs
+git commit -m "feat(data): add list_config, task_runs tables and task config columns"
+```
+
+---
+
+### Task 2: Models — ListConfigEntity, TaskRunEntity, AgentInfo
+
+**Files:**
+- Create: `src/ClaudeDo.Data/Models/ListConfigEntity.cs`
+- Create: `src/ClaudeDo.Data/Models/TaskRunEntity.cs`
+- Create: `src/ClaudeDo.Data/Models/AgentInfo.cs`
+- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:13-26`
+
+- [ ] **Step 1: Create ListConfigEntity**
+
+```csharp
+namespace ClaudeDo.Data.Models;
+
+public sealed class ListConfigEntity
+{
+ public required string ListId { get; init; }
+ public string? Model { get; set; }
+ public string? SystemPrompt { get; set; }
+ public string? AgentPath { get; set; }
+}
+```
+
+- [ ] **Step 2: Create TaskRunEntity**
+
+```csharp
+namespace ClaudeDo.Data.Models;
+
+public sealed class TaskRunEntity
+{
+ public required string Id { get; init; }
+ public required string TaskId { get; init; }
+ public required int RunNumber { get; init; }
+ public string? SessionId { get; set; }
+ public required bool IsRetry { get; init; }
+ public required string Prompt { get; init; }
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? ErrorMarkdown { get; set; }
+ public int? ExitCode { get; set; }
+ public int? TurnCount { get; set; }
+ public int? TokensIn { get; set; }
+ public int? TokensOut { get; set; }
+ public string? LogPath { get; set; }
+ public DateTime? StartedAt { get; set; }
+ public DateTime? FinishedAt { get; set; }
+}
+```
+
+- [ ] **Step 3: Create AgentInfo**
+
+```csharp
+namespace ClaudeDo.Data.Models;
+
+public sealed record AgentInfo(string Name, string Description, string Path);
+```
+
+- [ ] **Step 4: Add config fields to TaskEntity**
+
+Add after the `CommitType` property (line 25):
+
+```csharp
+public string? Model { get; set; }
+public string? SystemPrompt { get; set; }
+public string? AgentPath { get; set; }
+```
+
+- [ ] **Step 5: Build and verify**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
+```
+
+Expected: clean build, no errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Models/TaskRunEntity.cs src/ClaudeDo.Data/Models/AgentInfo.cs src/ClaudeDo.Data/Models/TaskEntity.cs
+git commit -m "feat(data): add ListConfigEntity, TaskRunEntity, AgentInfo models and task config fields"
+```
+
+---
+
+### Task 3: TaskRunRepository — CRUD for execution history
+
+**Files:**
+- Create: `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs`
+- Create: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Tests.Infrastructure;
+
+namespace ClaudeDo.Worker.Tests.Repositories;
+
+public sealed class TaskRunRepositoryTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly TaskRunRepository _repo;
+ private readonly TaskRepository _taskRepo;
+ private readonly ListRepository _listRepo;
+ private readonly string _listId;
+ private readonly string _taskId;
+
+ public TaskRunRepositoryTests()
+ {
+ _repo = new TaskRunRepository(_db.Factory);
+ _taskRepo = new TaskRepository(_db.Factory);
+ _listRepo = new ListRepository(_db.Factory);
+
+ _listId = Guid.NewGuid().ToString();
+ _listRepo.AddAsync(new ListEntity
+ {
+ Id = _listId, Name = "Test List", CreatedAt = DateTime.UtcNow
+ }).GetAwaiter().GetResult();
+
+ _taskId = Guid.NewGuid().ToString();
+ _taskRepo.AddAsync(new TaskEntity
+ {
+ Id = _taskId, ListId = _listId, Title = "Test Task",
+ Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow,
+ }).GetAwaiter().GetResult();
+ }
+
+ [Fact]
+ public async Task Add_And_GetById_Roundtrips()
+ {
+ var run = MakeRun(1);
+ await _repo.AddAsync(run);
+
+ var fetched = await _repo.GetByIdAsync(run.Id);
+ Assert.NotNull(fetched);
+ Assert.Equal(run.Id, fetched.Id);
+ Assert.Equal(run.TaskId, fetched.TaskId);
+ Assert.Equal(1, fetched.RunNumber);
+ Assert.False(fetched.IsRetry);
+ Assert.Equal("do the thing", fetched.Prompt);
+ }
+
+ [Fact]
+ public async Task GetByTaskId_Returns_Ordered_By_RunNumber()
+ {
+ await _repo.AddAsync(MakeRun(1));
+ await _repo.AddAsync(MakeRun(2, isRetry: true));
+ await _repo.AddAsync(MakeRun(3));
+
+ var runs = await _repo.GetByTaskIdAsync(_taskId);
+ Assert.Equal(3, runs.Count);
+ Assert.Equal(1, runs[0].RunNumber);
+ Assert.Equal(2, runs[1].RunNumber);
+ Assert.Equal(3, runs[2].RunNumber);
+ Assert.True(runs[1].IsRetry);
+ }
+
+ [Fact]
+ public async Task GetLatestByTaskId_Returns_Highest_RunNumber()
+ {
+ await _repo.AddAsync(MakeRun(1));
+ var run2 = MakeRun(2);
+ run2.SessionId = "sess-abc";
+ await _repo.AddAsync(run2);
+
+ var latest = await _repo.GetLatestByTaskIdAsync(_taskId);
+ Assert.NotNull(latest);
+ Assert.Equal(2, latest.RunNumber);
+ Assert.Equal("sess-abc", latest.SessionId);
+ }
+
+ [Fact]
+ public async Task Update_Persists_Completion_Fields()
+ {
+ var run = MakeRun(1);
+ await _repo.AddAsync(run);
+
+ run.SessionId = "sess-123";
+ run.ResultMarkdown = "## Done";
+ run.StructuredOutputJson = """{"summary":"all good"}""";
+ run.ExitCode = 0;
+ run.TurnCount = 5;
+ run.TokensIn = 1200;
+ run.TokensOut = 800;
+ run.FinishedAt = DateTime.UtcNow;
+ await _repo.UpdateAsync(run);
+
+ var fetched = await _repo.GetByIdAsync(run.Id);
+ Assert.NotNull(fetched);
+ Assert.Equal("sess-123", fetched.SessionId);
+ Assert.Equal("## Done", fetched.ResultMarkdown);
+ Assert.Equal("""{"summary":"all good"}""", fetched.StructuredOutputJson);
+ Assert.Equal(0, fetched.ExitCode);
+ Assert.Equal(5, fetched.TurnCount);
+ Assert.Equal(1200, fetched.TokensIn);
+ Assert.Equal(800, fetched.TokensOut);
+ }
+
+ [Fact]
+ public async Task GetLatestByTaskId_Returns_Null_When_No_Runs()
+ {
+ var latest = await _repo.GetLatestByTaskIdAsync(_taskId);
+ Assert.Null(latest);
+ }
+
+ private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
+ {
+ Id = Guid.NewGuid().ToString(),
+ TaskId = _taskId,
+ RunNumber = runNumber,
+ IsRetry = isRetry,
+ Prompt = "do the thing",
+ StartedAt = DateTime.UtcNow,
+ };
+
+ public void Dispose() => _db.Dispose();
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal
+```
+
+Expected: build error — `TaskRunRepository` does not exist.
+
+- [ ] **Step 3: Implement TaskRunRepository**
+
+```csharp
+using ClaudeDo.Data.Models;
+using Microsoft.Data.Sqlite;
+
+namespace ClaudeDo.Data.Repositories;
+
+public sealed class TaskRunRepository
+{
+ private readonly SqliteConnectionFactory _factory;
+
+ public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory;
+
+ public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
+ result_markdown, structured_output, error_markdown,
+ exit_code, turn_count, tokens_in, tokens_out,
+ log_path, started_at, finished_at)
+ VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
+ @result_markdown, @structured_output, @error_markdown,
+ @exit_code, @turn_count, @tokens_in, @tokens_out,
+ @log_path, @started_at, @finished_at)
+ """;
+ BindRun(cmd, entity);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE task_runs SET session_id = @session_id, result_markdown = @result_markdown,
+ structured_output = @structured_output, error_markdown = @error_markdown,
+ exit_code = @exit_code, turn_count = @turn_count,
+ tokens_in = @tokens_in, tokens_out = @tokens_out,
+ log_path = @log_path, finished_at = @finished_at
+ WHERE id = @id
+ """;
+ cmd.Parameters.AddWithValue("@id", entity.Id);
+ cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@log_path", (object?)entity.LogPath ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task GetByIdAsync(string runId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id";
+ cmd.Parameters.AddWithValue("@id", runId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return ReadRun(reader);
+ }
+
+ public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number";
+ cmd.Parameters.AddWithValue("@task_id", taskId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ var result = new List();
+ while (await reader.ReadAsync(ct))
+ result.Add(ReadRun(reader));
+ return result;
+ }
+
+ public async Task GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1";
+ cmd.Parameters.AddWithValue("@task_id", taskId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return ReadRun(reader);
+ }
+
+ private static void BindRun(SqliteCommand cmd, TaskRunEntity e)
+ {
+ cmd.Parameters.AddWithValue("@id", e.Id);
+ cmd.Parameters.AddWithValue("@task_id", e.TaskId);
+ cmd.Parameters.AddWithValue("@run_number", e.RunNumber);
+ cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0);
+ cmd.Parameters.AddWithValue("@prompt", e.Prompt);
+ cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
+ }
+
+ private static TaskRunEntity ReadRun(SqliteDataReader r) => new()
+ {
+ Id = r.GetString(0),
+ TaskId = r.GetString(1),
+ RunNumber = r.GetInt32(2),
+ SessionId = r.IsDBNull(3) ? null : r.GetString(3),
+ IsRetry = r.GetInt32(4) != 0,
+ Prompt = r.GetString(5),
+ ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6),
+ StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7),
+ ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8),
+ ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9),
+ TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10),
+ TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11),
+ TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12),
+ LogPath = r.IsDBNull(13) ? null : r.GetString(13),
+ StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14)),
+ FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15)),
+ };
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal
+```
+
+Expected: 5 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Data/Repositories/TaskRunRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs
+git commit -m "feat(data): add TaskRunRepository with CRUD and query methods"
+```
+
+---
+
+### Task 4: ListRepository — Config methods
+
+**Files:**
+- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs:116-124`
+- Create: `tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Tests.Infrastructure;
+
+namespace ClaudeDo.Worker.Tests.Repositories;
+
+public sealed class ListRepositoryConfigTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly ListRepository _repo;
+ private readonly string _listId;
+
+ public ListRepositoryConfigTests()
+ {
+ _repo = new ListRepository(_db.Factory);
+ _listId = Guid.NewGuid().ToString();
+ _repo.AddAsync(new ListEntity
+ {
+ Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow
+ }).GetAwaiter().GetResult();
+ }
+
+ [Fact]
+ public async Task GetConfig_Returns_Null_When_No_Config()
+ {
+ var config = await _repo.GetConfigAsync(_listId);
+ Assert.Null(config);
+ }
+
+ [Fact]
+ public async Task SetConfig_And_GetConfig_Roundtrips()
+ {
+ var config = new ListConfigEntity
+ {
+ ListId = _listId,
+ Model = "sonnet-4-6",
+ SystemPrompt = "You are helpful.",
+ AgentPath = "/home/user/.todo-app/agents/dev.md",
+ };
+ await _repo.SetConfigAsync(config);
+
+ var fetched = await _repo.GetConfigAsync(_listId);
+ Assert.NotNull(fetched);
+ Assert.Equal("sonnet-4-6", fetched.Model);
+ Assert.Equal("You are helpful.", fetched.SystemPrompt);
+ Assert.Equal("/home/user/.todo-app/agents/dev.md", fetched.AgentPath);
+ }
+
+ [Fact]
+ public async Task SetConfig_Upserts_On_Duplicate()
+ {
+ await _repo.SetConfigAsync(new ListConfigEntity
+ {
+ ListId = _listId, Model = "opus-4-6"
+ });
+ await _repo.SetConfigAsync(new ListConfigEntity
+ {
+ ListId = _listId, Model = "haiku-4-5"
+ });
+
+ var fetched = await _repo.GetConfigAsync(_listId);
+ Assert.NotNull(fetched);
+ Assert.Equal("haiku-4-5", fetched.Model);
+ }
+
+ public void Dispose() => _db.Dispose();
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal
+```
+
+Expected: build error — methods don't exist on `ListRepository`.
+
+- [ ] **Step 3: Add GetConfigAsync and SetConfigAsync to ListRepository**
+
+Add after the `RemoveTagAsync` method (before the `ReadList` helper):
+
+```csharp
+public async Task GetConfigAsync(string listId, CancellationToken ct = default)
+{
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id";
+ cmd.Parameters.AddWithValue("@list_id", listId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return new ListConfigEntity
+ {
+ ListId = reader.GetString(0),
+ Model = reader.IsDBNull(1) ? null : reader.GetString(1),
+ SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2),
+ AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3),
+ };
+}
+
+public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
+{
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path)
+ VALUES (@list_id, @model, @system_prompt, @agent_path)
+ """;
+ cmd.Parameters.AddWithValue("@list_id", entity.ListId);
+ cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
+ await cmd.ExecuteNonQueryAsync(ct);
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal
+```
+
+Expected: 3 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Data/Repositories/ListRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs
+git commit -m "feat(data): add GetConfigAsync and SetConfigAsync to ListRepository"
+```
+
+---
+
+### Task 5: TaskRepository — Read/write config columns
+
+**Files:**
+- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs:39-51,53-66,77-87,89-101,266-280,282-296`
+
+The `AddAsync`, `UpdateAsync`, `GetByIdAsync`, `GetByListAsync`, `BindTask`, and `ReadTask` methods must include the three new columns (`model`, `system_prompt`, `agent_path`).
+
+- [ ] **Step 1: Update BindTask to include new columns**
+
+Replace the `BindTask` method:
+
+```csharp
+private static void BindTask(SqliteCommand cmd, TaskEntity e)
+{
+ cmd.Parameters.AddWithValue("@id", e.Id);
+ cmd.Parameters.AddWithValue("@list_id", e.ListId);
+ cmd.Parameters.AddWithValue("@title", e.Title);
+ cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
+ cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
+ cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
+ cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
+}
+```
+
+- [ ] **Step 2: Update AddAsync SQL to include new columns**
+
+```csharp
+cmd.CommandText = """
+ INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
+ result, log_path, created_at, started_at, finished_at, commit_type,
+ model, system_prompt, agent_path)
+ VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
+ @result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
+ @model, @system_prompt, @agent_path)
+ """;
+```
+
+- [ ] **Step 3: Update UpdateAsync SQL to include new columns**
+
+```csharp
+cmd.CommandText = """
+ UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
+ status = @status, scheduled_for = @scheduled_for, result = @result,
+ log_path = @log_path, started_at = @started_at,
+ finished_at = @finished_at, commit_type = @commit_type,
+ model = @model, system_prompt = @system_prompt, agent_path = @agent_path
+ WHERE id = @id
+ """;
+```
+
+- [ ] **Step 4: Update all SELECT statements and ReadTask**
+
+Every `SELECT` in `GetByIdAsync`, `GetByListAsync`, and `GetNextQueuedAgentTaskAsync` must add the three new columns. Update the column list to:
+
+```
+SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks ...
+```
+
+Update `ReadTask`:
+
+```csharp
+private static TaskEntity ReadTask(SqliteDataReader r) => new()
+{
+ Id = r.GetString(0),
+ ListId = r.GetString(1),
+ Title = r.GetString(2),
+ Description = r.IsDBNull(3) ? null : r.GetString(3),
+ Status = FromDb(r.GetString(4)),
+ ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
+ Result = r.IsDBNull(6) ? null : r.GetString(6),
+ LogPath = r.IsDBNull(7) ? null : r.GetString(7),
+ CreatedAt = DateTime.Parse(r.GetString(8)),
+ StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
+ FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
+ CommitType = r.GetString(11),
+ Model = r.IsDBNull(12) ? null : r.GetString(12),
+ SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
+ AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
+};
+```
+
+- [ ] **Step 5: Run all existing TaskRepository tests**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryTests" -v minimal
+```
+
+Expected: all existing tests still pass (new columns are nullable, backward compatible).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Data/Repositories/TaskRepository.cs
+git commit -m "feat(data): extend TaskRepository with model, system_prompt, agent_path columns"
+```
+
+---
+
+### Task 6: ClaudeArgsBuilder — CLI argument construction
+
+**Files:**
+- Create: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs`
+- Create: `tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Worker.Runner;
+
+namespace ClaudeDo.Worker.Tests.Runner;
+
+public sealed class ClaudeArgsBuilderTests
+{
+ private readonly ClaudeArgsBuilder _builder = new();
+
+ [Fact]
+ public void Default_Config_Produces_Base_Args()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
+ Assert.Contains("-p", args);
+ Assert.Contains("--output-format stream-json", args);
+ Assert.Contains("--verbose", args);
+ Assert.Contains("--dangerously-skip-permissions", args);
+ Assert.Contains("--json-schema", args);
+ Assert.DoesNotContain("--model", args);
+ Assert.DoesNotContain("--append-system-prompt", args);
+ Assert.DoesNotContain("--agents", args);
+ Assert.DoesNotContain("--resume", args);
+ }
+
+ [Fact]
+ public void Model_Adds_Model_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig("sonnet-4-6", null, null, null));
+ Assert.Contains("--model sonnet-4-6", args);
+ }
+
+ [Fact]
+ public void SystemPrompt_Adds_Append_System_Prompt_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, "Be concise.", null, null));
+ Assert.Contains("--append-system-prompt", args);
+ Assert.Contains("Be concise.", args);
+ }
+
+ [Fact]
+ public void AgentPath_Adds_Agents_Flag_As_Json()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, "/path/to/agent.md", null));
+ Assert.Contains("--agents", args);
+ Assert.Contains("/path/to/agent.md", args);
+ }
+
+ [Fact]
+ public void ResumeSessionId_Adds_Resume_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, null, "sess-abc-123"));
+ Assert.Contains("--resume sess-abc-123", args);
+ }
+
+ [Fact]
+ public void All_Options_Set_Includes_All_Flags()
+ {
+ var args = _builder.Build(new ClaudeRunConfig("opus-4-6", "Be thorough.", "/agents/dev.md", "sess-xyz"));
+ Assert.Contains("--model opus-4-6", args);
+ Assert.Contains("--append-system-prompt", args);
+ Assert.Contains("--agents", args);
+ Assert.Contains("--resume sess-xyz", args);
+ Assert.Contains("--json-schema", args);
+ }
+
+ [Fact]
+ public void SystemPrompt_With_Quotes_Is_Escaped()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
+ // Should not break argument parsing — the prompt is passed as a single argument
+ Assert.Contains("--append-system-prompt", args);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal
+```
+
+Expected: build error — `ClaudeArgsBuilder` and `ClaudeRunConfig` don't exist.
+
+- [ ] **Step 3: Implement ClaudeArgsBuilder**
+
+```csharp
+using System.Text;
+using System.Text.Json;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed record ClaudeRunConfig(
+ string? Model,
+ string? SystemPrompt,
+ string? AgentPath,
+ string? ResumeSessionId
+);
+
+public sealed class ClaudeArgsBuilder
+{
+ private static readonly string ResultSchema = JsonSerializer.Serialize(new
+ {
+ type = "object",
+ properties = new
+ {
+ summary = new { type = "string" },
+ files_changed = new { type = "array", items = new { type = "string" } },
+ commit_type = new { type = "string" },
+ },
+ required = new[] { "summary" },
+ });
+
+ public string Build(ClaudeRunConfig config)
+ {
+ var args = new List
+ {
+ "-p",
+ "--output-format stream-json",
+ "--verbose",
+ "--dangerously-skip-permissions",
+ };
+
+ if (config.Model is not null)
+ args.Add($"--model {config.Model}");
+
+ if (config.SystemPrompt is not null)
+ args.Add($"--append-system-prompt {Escape(config.SystemPrompt)}");
+
+ if (config.AgentPath is not null)
+ {
+ var agentJson = JsonSerializer.Serialize(new[] { new { file = config.AgentPath } });
+ args.Add($"--agents {Escape(agentJson)}");
+ }
+
+ args.Add($"--json-schema {Escape(ResultSchema)}");
+
+ if (config.ResumeSessionId is not null)
+ args.Add($"--resume {config.ResumeSessionId}");
+
+ return string.Join(" ", args);
+ }
+
+ private static string Escape(string value)
+ {
+ // Wrap in double quotes if the value contains spaces or special characters.
+ if (value.Contains(' ') || value.Contains('"') || value.Contains('\''))
+ {
+ var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+ return $"\"{escaped}\"";
+ }
+ return value;
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal
+```
+
+Expected: 7 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs
+git commit -m "feat(worker): add ClaudeArgsBuilder for dynamic CLI argument construction"
+```
+
+---
+
+### Task 7: StreamAnalyzer — Rich NDJSON stream parsing
+
+**Files:**
+- Create: `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`
+- Create: `src/ClaudeDo.Worker/Runner/StreamResult.cs`
+- Create: `tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Worker.Runner;
+
+namespace ClaudeDo.Worker.Tests.Runner;
+
+public sealed class StreamAnalyzerTests
+{
+ [Fact]
+ public void Extracts_Result_Markdown()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"result","result":"## Done","session_id":"sess-1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal("## Done", result.ResultMarkdown);
+ Assert.Equal("sess-1", result.SessionId);
+ }
+
+ [Fact]
+ public void Extracts_Structured_Output()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"result","result":"ok","structured_output":{"summary":"all good"},"session_id":"s1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal("ok", result.ResultMarkdown);
+ Assert.Contains("all good", result.StructuredOutputJson);
+ }
+
+ [Fact]
+ public void Counts_Assistant_Turns()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
+ analyzer.ProcessLine("""{"type":"assistant","message":"working on it"}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal(2, result.TurnCount);
+ }
+
+ [Fact]
+ public void Accumulates_Token_Usage()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":50}}}}""");
+ analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":200,"output_tokens":80}}}}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal(300, result.TokensIn);
+ Assert.Equal(130, result.TokensOut);
+ }
+
+ [Fact]
+ public void Counts_Api_Retry_Events()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":1,"error":"rate_limit"}""");
+ analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":2,"error":"rate_limit"}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal(2, result.ApiRetryCount);
+ }
+
+ [Fact]
+ public void Malformed_Json_Is_Ignored()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("not json {{{");
+ analyzer.ProcessLine("");
+ analyzer.ProcessLine(" ");
+
+ var result = analyzer.GetResult();
+ Assert.Null(result.ResultMarkdown);
+ Assert.Equal(0, result.TurnCount);
+ }
+
+ [Fact]
+ public void No_Result_Event_Returns_Null_Fields()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Null(result.ResultMarkdown);
+ Assert.Null(result.SessionId);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal
+```
+
+Expected: build error — `StreamAnalyzer` and `StreamResult` don't exist.
+
+- [ ] **Step 3: Create StreamResult**
+
+```csharp
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class StreamResult
+{
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? SessionId { get; set; }
+ public int TurnCount { get; set; }
+ public int TokensIn { get; set; }
+ public int TokensOut { get; set; }
+ public int ApiRetryCount { get; set; }
+}
+```
+
+- [ ] **Step 4: Implement StreamAnalyzer**
+
+```csharp
+using System.Text.Json;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class StreamAnalyzer
+{
+ private string? _resultMarkdown;
+ private string? _structuredOutputJson;
+ private string? _sessionId;
+ private int _turnCount;
+ private int _tokensIn;
+ private int _tokensOut;
+ private int _apiRetryCount;
+
+ public void ProcessLine(string ndjsonLine)
+ {
+ if (string.IsNullOrWhiteSpace(ndjsonLine))
+ return;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(ndjsonLine);
+ var root = doc.RootElement;
+
+ if (!root.TryGetProperty("type", out var typeProp))
+ return;
+
+ var type = typeProp.GetString();
+
+ switch (type)
+ {
+ case "result":
+ if (root.TryGetProperty("result", out var resultProp))
+ _resultMarkdown = resultProp.GetString();
+ if (root.TryGetProperty("structured_output", out var structuredProp))
+ _structuredOutputJson = structuredProp.ToString();
+ if (root.TryGetProperty("session_id", out var sessionProp))
+ _sessionId = sessionProp.GetString();
+ break;
+
+ case "assistant":
+ _turnCount++;
+ break;
+
+ case "system":
+ if (root.TryGetProperty("subtype", out var subtypeProp) &&
+ subtypeProp.GetString() == "api_retry")
+ _apiRetryCount++;
+ break;
+
+ case "stream_event":
+ TryAccumulateUsage(root);
+ break;
+ }
+ }
+ catch (JsonException)
+ {
+ // Malformed JSON — skip.
+ }
+ }
+
+ public StreamResult GetResult() => new()
+ {
+ ResultMarkdown = _resultMarkdown,
+ StructuredOutputJson = _structuredOutputJson,
+ SessionId = _sessionId,
+ TurnCount = _turnCount,
+ TokensIn = _tokensIn,
+ TokensOut = _tokensOut,
+ ApiRetryCount = _apiRetryCount,
+ };
+
+ private void TryAccumulateUsage(JsonElement root)
+ {
+ // Path: .event.message.usage.input_tokens / .event.message.usage.output_tokens
+ // Also: .event.delta.usage for delta events
+ if (!root.TryGetProperty("event", out var eventProp))
+ return;
+
+ if (eventProp.TryGetProperty("message", out var msgProp) &&
+ msgProp.TryGetProperty("usage", out var usageProp))
+ {
+ AccumulateUsage(usageProp);
+ }
+ }
+
+ private void AccumulateUsage(JsonElement usage)
+ {
+ if (usage.TryGetProperty("input_tokens", out var inp))
+ _tokensIn += inp.GetInt32();
+ if (usage.TryGetProperty("output_tokens", out var outp))
+ _tokensOut += outp.GetInt32();
+ }
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal
+```
+
+Expected: 7 tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/StreamResult.cs src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
+git commit -m "feat(worker): add StreamAnalyzer for rich NDJSON stream parsing"
+```
+
+---
+
+### Task 8: RunResult — Extend with stream metrics
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Runner/RunResult.cs:1-11`
+
+- [ ] **Step 1: Update RunResult**
+
+Replace entire file:
+
+```csharp
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class RunResult
+{
+ public required int ExitCode { get; init; }
+ public string? ResultMarkdown { get; init; }
+ public string? ErrorMarkdown { get; init; }
+ public string? StructuredOutputJson { get; init; }
+ public string? SessionId { get; init; }
+ public int TurnCount { get; init; }
+ public int TokensIn { get; init; }
+ public int TokensOut { get; init; }
+
+ public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null;
+}
+```
+
+- [ ] **Step 2: Build to check for downstream breakage**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+```
+
+Expected: build succeeds — new properties are optional (init-only with defaults). The existing `new RunResult { ExitCode = ..., ResultMarkdown = ... }` sites still compile.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/RunResult.cs
+git commit -m "feat(worker): extend RunResult with structured output, session ID, and token metrics"
+```
+
+---
+
+### Task 9: IClaudeProcess + ClaudeProcess — Simplified interface
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Runner/IClaudeProcess.cs:1-12`
+- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:1-96`
+
+- [ ] **Step 1: Update IClaudeProcess**
+
+Replace entire file:
+
+```csharp
+namespace ClaudeDo.Worker.Runner;
+
+public interface IClaudeProcess
+{
+ Task RunAsync(
+ string arguments,
+ string prompt,
+ string workingDirectory,
+ Func onStdoutLine,
+ CancellationToken ct);
+}
+```
+
+- [ ] **Step 2: Update ClaudeProcess**
+
+Replace entire file:
+
+```csharp
+using System.Diagnostics;
+using System.Text;
+using ClaudeDo.Worker.Config;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class ClaudeProcess : IClaudeProcess
+{
+ private readonly WorkerConfig _cfg;
+ private readonly ILogger _logger;
+
+ public ClaudeProcess(WorkerConfig cfg, ILogger logger)
+ {
+ _cfg = cfg;
+ _logger = logger;
+ }
+
+ public async Task RunAsync(
+ string arguments,
+ string prompt,
+ string workingDirectory,
+ Func onStdoutLine,
+ CancellationToken ct)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = _cfg.ClaudeBin,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+
+ using var process = new Process { StartInfo = psi };
+ process.Start();
+
+ // Write prompt to stdin, then close.
+ await process.StandardInput.WriteAsync(prompt);
+ process.StandardInput.Close();
+
+ var analyzer = new StreamAnalyzer();
+ var lastStderr = new StringBuilder();
+
+ // Register cancellation to kill the process tree.
+ await using var ctr = ct.Register(() =>
+ {
+ try { process.Kill(entireProcessTree: true); }
+ catch { /* already exited */ }
+ });
+
+ // Read stdout and stderr concurrently.
+ var stdoutTask = Task.Run(async () =>
+ {
+ while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
+ {
+ if (string.IsNullOrEmpty(line)) continue;
+ await onStdoutLine(line);
+ analyzer.ProcessLine(line);
+ }
+ }, ct);
+
+ var stderrTask = Task.Run(async () =>
+ {
+ while (await process.StandardError.ReadLineAsync(ct) is { } line)
+ {
+ if (string.IsNullOrEmpty(line)) continue;
+ lastStderr.AppendLine(line);
+ await onStdoutLine($"[stderr] {line}");
+ }
+ }, ct);
+
+ await Task.WhenAll(stdoutTask, stderrTask);
+ await process.WaitForExitAsync(ct);
+
+ var exitCode = process.ExitCode;
+ var streamResult = analyzer.GetResult();
+
+ if (exitCode == 0 && streamResult.ResultMarkdown is not null)
+ {
+ return new RunResult
+ {
+ ExitCode = exitCode,
+ ResultMarkdown = streamResult.ResultMarkdown,
+ StructuredOutputJson = streamResult.StructuredOutputJson,
+ SessionId = streamResult.SessionId,
+ TurnCount = streamResult.TurnCount,
+ TokensIn = streamResult.TokensIn,
+ TokensOut = streamResult.TokensOut,
+ };
+ }
+
+ var error = lastStderr.Length > 0
+ ? lastStderr.ToString().Trim()
+ : $"Claude exited with code {exitCode} and no result.";
+
+ return new RunResult
+ {
+ ExitCode = exitCode,
+ ErrorMarkdown = error,
+ SessionId = streamResult.SessionId,
+ TurnCount = streamResult.TurnCount,
+ TokensIn = streamResult.TokensIn,
+ TokensOut = streamResult.TokensOut,
+ };
+ }
+}
+```
+
+- [ ] **Step 3: Update FakeClaudeProcess in tests**
+
+The `FakeClaudeProcess` in `QueueServiceTests.cs` must match the new `IClaudeProcess` signature. Update it:
+
+```csharp
+internal sealed class FakeClaudeProcess : IClaudeProcess
+{
+ private readonly Func, CancellationToken, Task> _handler;
+ private int _callCount;
+
+ public int CallCount => _callCount;
+
+ public FakeClaudeProcess(
+ Func, CancellationToken, Task>? handler = null)
+ {
+ _handler = handler ?? ((_, _, _, _, _) =>
+ Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
+ }
+
+ public async Task RunAsync(string arguments, string prompt, string workingDirectory,
+ Func onStdoutLine, CancellationToken ct)
+ {
+ Interlocked.Increment(ref _callCount);
+ return await _handler(prompt, workingDirectory, arguments, onStdoutLine, ct);
+ }
+}
+```
+
+Update all sites in `QueueServiceTests.cs` that construct `FakeClaudeProcess` with a handler lambda — the lambda signature changes from 6 parameters to 5 (remove `logPath` and `taskId`).
+
+- [ ] **Step 4: Build and run all tests**
+
+Run:
+```bash
+dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/IClaudeProcess.cs src/ClaudeDo.Worker/Runner/ClaudeProcess.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
+git commit -m "refactor(worker): simplify ClaudeProcess to accept pre-built args and use StreamAnalyzer"
+```
+
+---
+
+### Task 10: AgentFileService — Filesystem agent management
+
+**Files:**
+- Create: `src/ClaudeDo.Worker/Services/AgentFileService.cs`
+- Create: `tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Worker.Services;
+
+namespace ClaudeDo.Worker.Tests.Services;
+
+public sealed class AgentFileServiceTests : IDisposable
+{
+ private readonly string _agentDir;
+ private readonly AgentFileService _service;
+
+ public AgentFileServiceTests()
+ {
+ _agentDir = Path.Combine(Path.GetTempPath(), $"claudedo_agents_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_agentDir);
+ _service = new AgentFileService(_agentDir);
+ }
+
+ [Fact]
+ public async Task Scan_Returns_Empty_For_Empty_Directory()
+ {
+ var agents = await _service.ScanAsync();
+ Assert.Empty(agents);
+ }
+
+ [Fact]
+ public async Task Scan_Parses_Frontmatter()
+ {
+ var content = """
+ ---
+ name: Test Agent
+ description: A test agent for unit tests
+ ---
+
+ You are a test agent.
+ """;
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "test.md"), content);
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("Test Agent", agents[0].Name);
+ Assert.Equal("A test agent for unit tests", agents[0].Description);
+ Assert.EndsWith("test.md", agents[0].Path);
+ }
+
+ [Fact]
+ public async Task Scan_Uses_Filename_When_No_Frontmatter()
+ {
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "simple.md"), "Just instructions.");
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("simple", agents[0].Name);
+ Assert.Equal("", agents[0].Description);
+ }
+
+ [Fact]
+ public async Task Write_And_Read_Roundtrips()
+ {
+ var path = Path.Combine(_agentDir, "new-agent.md");
+ var content = "---\nname: New\ndescription: Desc\n---\nBody";
+ await _service.WriteAsync(path, content);
+
+ var read = await _service.ReadAsync(path);
+ Assert.Equal(content, read);
+ }
+
+ [Fact]
+ public async Task Delete_Removes_File()
+ {
+ var path = Path.Combine(_agentDir, "to-delete.md");
+ await File.WriteAllTextAsync(path, "temp");
+
+ await _service.DeleteAsync(path);
+ Assert.False(File.Exists(path));
+ }
+
+ [Fact]
+ public async Task Scan_Ignores_Non_Md_Files()
+ {
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "notes.txt"), "not an agent");
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "agent.md"), "---\nname: Real\ndescription: Yes\n---\nBody");
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("Real", agents[0].Name);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_agentDir, true); } catch { }
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal
+```
+
+Expected: build error — `AgentFileService` doesn't exist.
+
+- [ ] **Step 3: Implement AgentFileService**
+
+```csharp
+using ClaudeDo.Data.Models;
+
+namespace ClaudeDo.Worker.Services;
+
+public sealed class AgentFileService
+{
+ private readonly string _agentsDir;
+
+ public AgentFileService(string agentsDir)
+ {
+ _agentsDir = agentsDir;
+ }
+
+ public Task> ScanAsync(CancellationToken ct = default)
+ {
+ var agents = new List();
+
+ if (!Directory.Exists(_agentsDir))
+ return Task.FromResult(agents);
+
+ foreach (var file in Directory.EnumerateFiles(_agentsDir, "*.md"))
+ {
+ ct.ThrowIfCancellationRequested();
+ var (name, description) = ParseFrontmatter(file);
+ agents.Add(new AgentInfo(name, description, file));
+ }
+
+ agents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
+ return Task.FromResult(agents);
+ }
+
+ public async Task ReadAsync(string path, CancellationToken ct = default)
+ {
+ return await File.ReadAllTextAsync(path, ct);
+ }
+
+ public async Task WriteAsync(string path, string content, CancellationToken ct = default)
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (dir is not null)
+ Directory.CreateDirectory(dir);
+ await File.WriteAllTextAsync(path, content, ct);
+ }
+
+ public Task DeleteAsync(string path, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (File.Exists(path))
+ File.Delete(path);
+ return Task.CompletedTask;
+ }
+
+ private static (string name, string description) ParseFrontmatter(string filePath)
+ {
+ var fileName = Path.GetFileNameWithoutExtension(filePath);
+ string name = fileName;
+ string description = "";
+
+ try
+ {
+ using var reader = new StreamReader(filePath);
+ var firstLine = reader.ReadLine();
+ if (firstLine?.Trim() != "---")
+ return (name, description);
+
+ while (reader.ReadLine() is { } line)
+ {
+ if (line.Trim() == "---")
+ break;
+
+ if (line.StartsWith("name:"))
+ name = line["name:".Length..].Trim();
+ else if (line.StartsWith("description:"))
+ description = line["description:".Length..].Trim();
+ }
+ }
+ catch
+ {
+ // Can't read file — use filename fallback.
+ }
+
+ return (name, description);
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal
+```
+
+Expected: 6 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Services/AgentFileService.cs tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs
+git commit -m "feat(worker): add AgentFileService for filesystem agent management"
+```
+
+---
+
+### Task 11: TaskRunner — Refactor with retry/continue and config resolution
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:1-149`
+
+This is the largest change. TaskRunner gains config resolution, task_runs tracking, auto-retry, and a new `ContinueAsync` method.
+
+- [ ] **Step 1: Rewrite TaskRunner**
+
+Replace entire file:
+
+```csharp
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.Hub;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class TaskRunner
+{
+ private readonly IClaudeProcess _claude;
+ private readonly TaskRepository _taskRepo;
+ private readonly TaskRunRepository _runRepo;
+ private readonly ListRepository _listRepo;
+ private readonly WorktreeRepository _wtRepo; // for ContinueAsync worktree lookup
+ private readonly HubBroadcaster _broadcaster;
+ private readonly WorktreeManager _wtManager;
+ private readonly ClaudeArgsBuilder _argsBuilder;
+ private readonly WorkerConfig _cfg;
+ private readonly ILogger _logger;
+
+ public TaskRunner(
+ IClaudeProcess claude,
+ TaskRepository taskRepo,
+ TaskRunRepository runRepo,
+ ListRepository listRepo,
+ WorktreeRepository wtRepo,
+ HubBroadcaster broadcaster,
+ WorktreeManager wtManager,
+ ClaudeArgsBuilder argsBuilder,
+ WorkerConfig cfg,
+ ILogger logger)
+ {
+ _claude = claude;
+ _taskRepo = taskRepo;
+ _runRepo = runRepo;
+ _listRepo = listRepo;
+ _wtRepo = wtRepo;
+ _broadcaster = broadcaster;
+ _wtManager = wtManager;
+ _argsBuilder = argsBuilder;
+ _cfg = cfg;
+ _logger = logger;
+ }
+
+ public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
+ {
+ try
+ {
+ var list = await _listRepo.GetByIdAsync(task.ListId, ct);
+ if (list is null)
+ {
+ await MarkFailed(task.Id, slot, "List not found.");
+ return;
+ }
+
+ // Determine working directory: worktree or sandbox.
+ WorktreeContext? wtCtx = null;
+ string runDir;
+
+ if (list.WorkingDir is not null)
+ {
+ try
+ {
+ wtCtx = await _wtManager.CreateAsync(task, list, ct);
+ runDir = wtCtx.WorktreePath;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id);
+ await MarkFailed(task.Id, slot, $"Worktree creation failed: {ex.Message}");
+ return;
+ }
+ }
+ else
+ {
+ runDir = Path.Combine(_cfg.SandboxRoot, task.Id);
+ Directory.CreateDirectory(runDir);
+ }
+
+ // Resolve config: task overrides > list config > null.
+ var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
+ var resolvedConfig = new ClaudeRunConfig(
+ Model: task.Model ?? listConfig?.Model,
+ SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
+ AgentPath: task.AgentPath ?? listConfig?.AgentPath,
+ ResumeSessionId: null
+ );
+
+ var now = DateTime.UtcNow;
+ await _taskRepo.MarkRunningAsync(task.Id, now, ct);
+ await _broadcaster.TaskStarted(slot, task.Id, now);
+
+ // Build prompt.
+ var prompt = string.IsNullOrWhiteSpace(task.Description)
+ ? task.Title
+ : $"{task.Title}\n\n{task.Description.Trim()}";
+
+ // Run 1.
+ var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
+
+ if (result.IsSuccess)
+ {
+ await HandleSuccess(task, list, slot, wtCtx, result, ct);
+ }
+ else
+ {
+ // Auto-retry: one attempt if we have a session ID.
+ if (result.SessionId is not null)
+ {
+ _logger.LogInformation("Auto-retrying task {TaskId} with session {SessionId}", task.Id, result.SessionId);
+ var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
+ var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
+
+ await _broadcaster.RunCreated(task.Id, 2, true);
+ var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
+
+ if (retryResult.IsSuccess)
+ {
+ await HandleSuccess(task, list, slot, wtCtx, retryResult, ct);
+ }
+ else
+ {
+ await HandleFailure(task.Id, slot, retryResult);
+ }
+ }
+ else
+ {
+ await HandleFailure(task.Id, slot, result);
+ }
+ }
+
+ await _broadcaster.TaskUpdated(task.Id);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Task {TaskId} was cancelled", task.Id);
+ await MarkFailed(task.Id, slot, "Task cancelled.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
+ await MarkFailed(task.Id, slot, $"Unhandled error: {ex.Message}");
+ }
+ }
+
+ public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
+ {
+ var task = await _taskRepo.GetByIdAsync(taskId, ct)
+ ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
+
+ var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct)
+ ?? throw new InvalidOperationException("No previous run to continue.");
+
+ if (lastRun.SessionId is null)
+ throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
+
+ var list = await _listRepo.GetByIdAsync(task.ListId, ct)
+ ?? throw new InvalidOperationException("List not found.");
+
+ var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
+ var resolvedConfig = new ClaudeRunConfig(
+ Model: task.Model ?? listConfig?.Model,
+ SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
+ AgentPath: task.AgentPath ?? listConfig?.AgentPath,
+ ResumeSessionId: lastRun.SessionId
+ );
+
+ // Determine run directory from existing worktree or sandbox.
+ string runDir;
+ WorktreeContext? wtCtx = null;
+ var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct);
+ if (worktree is not null)
+ {
+ runDir = worktree.Path;
+ wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
+ }
+ else
+ {
+ runDir = Path.Combine(_cfg.SandboxRoot, taskId);
+ }
+
+ var now = DateTime.UtcNow;
+ await _taskRepo.MarkRunningAsync(taskId, now, ct);
+ await _broadcaster.TaskStarted(slot, taskId, now);
+
+ var nextRunNumber = lastRun.RunNumber + 1;
+ await _broadcaster.RunCreated(taskId, nextRunNumber, false);
+ var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
+
+ if (result.IsSuccess)
+ {
+ await HandleSuccess(task, list, slot, wtCtx, result, ct);
+ }
+ else
+ {
+ await HandleFailure(taskId, slot, result);
+ }
+
+ await _broadcaster.TaskUpdated(taskId);
+ }
+
+ private async Task RunOnceAsync(
+ string taskId, string slot, string runDir, ClaudeRunConfig config,
+ int runNumber, bool isRetry, string prompt, CancellationToken ct)
+ {
+ var runId = Guid.NewGuid().ToString();
+ var logPath = Path.Combine(_cfg.LogRoot, $"{taskId}_run{runNumber}.ndjson");
+
+ var run = new TaskRunEntity
+ {
+ Id = runId,
+ TaskId = taskId,
+ RunNumber = runNumber,
+ IsRetry = isRetry,
+ Prompt = prompt,
+ LogPath = logPath,
+ StartedAt = DateTime.UtcNow,
+ };
+ await _runRepo.AddAsync(run, ct);
+
+ var arguments = _argsBuilder.Build(config);
+
+ await using var logWriter = new LogWriter(logPath);
+
+ var result = await _claude.RunAsync(
+ arguments,
+ prompt,
+ runDir,
+ async line =>
+ {
+ await logWriter.WriteLineAsync(line, ct);
+ await _broadcaster.TaskMessage(taskId, line);
+ },
+ ct);
+
+ // Update the run record with results.
+ run.SessionId = result.SessionId;
+ run.ResultMarkdown = result.ResultMarkdown;
+ run.StructuredOutputJson = result.StructuredOutputJson;
+ run.ErrorMarkdown = result.ErrorMarkdown;
+ run.ExitCode = result.ExitCode;
+ run.TurnCount = result.TurnCount;
+ run.TokensIn = result.TokensIn;
+ run.TokensOut = result.TokensOut;
+ run.FinishedAt = DateTime.UtcNow;
+ await _runRepo.UpdateAsync(run, ct);
+
+ // Update denormalized fields on the task.
+ await _taskRepo.SetLogPathAsync(taskId, logPath, ct);
+
+ return result;
+ }
+
+ private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
+ {
+ if (wtCtx is not null)
+ {
+ var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
+ if (committed)
+ await _broadcaster.WorktreeUpdated(task.Id);
+ }
+
+ var finishedAt = DateTime.UtcNow;
+ await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct);
+ await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
+ _logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
+ task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
+ }
+
+ private async Task HandleFailure(string taskId, string slot, RunResult result)
+ {
+ var finishedAt = DateTime.UtcNow;
+ await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown);
+ await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
+ _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
+ }
+
+ private async Task MarkFailed(string taskId, string slot, string error)
+ {
+ try
+ {
+ var now = DateTime.UtcNow;
+ await _taskRepo.MarkFailedAsync(taskId, now, error);
+ await _broadcaster.TaskFinished(slot, taskId, "failed", now);
+ await _broadcaster.TaskUpdated(taskId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to mark task {TaskId} as failed", taskId);
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Build and check for compilation errors**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+```
+
+Expected: may fail on missing DI registrations (Program.cs) — that's Task 12.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
+git commit -m "refactor(worker): rewrite TaskRunner with config resolution, retry, and continue support"
+```
+
+---
+
+### Task 12: HubBroadcaster + WorkerHub — New methods
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:1-25`
+- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:1-44`
+
+- [ ] **Step 1: Add RunCreated broadcast to HubBroadcaster**
+
+Add after the `TaskUpdated` method:
+
+```csharp
+public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
+ _hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
+```
+
+- [ ] **Step 2: Add ContinueTask, GetAgents, RefreshAgents to WorkerHub**
+
+Update WorkerHub to inject `AgentFileService` and expose new methods. Replace entire file:
+
+```csharp
+using System.Reflection;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Worker.Services;
+using Microsoft.AspNetCore.SignalR;
+
+namespace ClaudeDo.Worker.Hub;
+
+public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
+{
+ private static readonly string Version =
+ Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
+
+ private readonly QueueService _queue;
+ private readonly AgentFileService _agentService;
+
+ public WorkerHub(QueueService queue, AgentFileService agentService)
+ {
+ _queue = queue;
+ _agentService = agentService;
+ }
+
+ public string Ping() => $"pong v{Version}";
+
+ public IReadOnlyList GetActive()
+ {
+ return _queue.GetActive()
+ .Select(a => (object)new { slot = a.slot, taskId = a.taskId, startedAt = a.startedAt })
+ .ToList();
+ }
+
+ public async Task RunNow(string taskId)
+ {
+ try
+ {
+ await _queue.RunNow(taskId);
+ }
+ catch (InvalidOperationException)
+ {
+ throw new HubException("override slot busy");
+ }
+ catch (KeyNotFoundException)
+ {
+ throw new HubException("task not found");
+ }
+ }
+
+ public async Task ContinueTask(string taskId, string followUpPrompt)
+ {
+ try
+ {
+ return await _queue.ContinueTask(taskId, followUpPrompt);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new HubException(ex.Message);
+ }
+ catch (KeyNotFoundException)
+ {
+ throw new HubException("task not found");
+ }
+ }
+
+ public bool CancelTask(string taskId) => _queue.CancelTask(taskId);
+
+ public void WakeQueue() => _queue.WakeQueue();
+
+ public async Task> GetAgents() => await _agentService.ScanAsync();
+
+ public async Task RefreshAgents() => await _agentService.ScanAsync();
+}
+```
+
+- [ ] **Step 3: Build to check**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+```
+
+Expected: may fail — `QueueService.ContinueTask` doesn't exist yet (Task 13).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
+git commit -m "feat(worker): add ContinueTask, GetAgents, RefreshAgents hub methods and RunCreated broadcast"
+```
+
+---
+
+### Task 13: QueueService — Route ContinueTask
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs:57-76`
+
+- [ ] **Step 1: Add ContinueTask method to QueueService**
+
+Add after the `RunNow` method:
+
+```csharp
+public async Task ContinueTask(string taskId, string followUpPrompt)
+{
+ var task = await _taskRepo.GetByIdAsync(taskId)
+ ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
+
+ if (task.Status == Data.Models.TaskStatus.Running)
+ throw new InvalidOperationException("Task is currently running.");
+
+ lock (_lock)
+ {
+ if (_overrideSlot is not null)
+ throw new InvalidOperationException("override slot busy");
+
+ var cts = new CancellationTokenSource();
+ _overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
+
+ _ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
+ {
+ lock (_lock) { _overrideSlot = null; }
+ }, TaskScheduler.Default);
+ }
+
+ return taskId;
+}
+
+private async Task RunContinueInSlotAsync(string taskId, string followUpPrompt, CancellationToken ct)
+{
+ try
+ {
+ _logger.LogInformation("Continuing task {TaskId} in override slot", taskId);
+ await _runner.ContinueAsync(taskId, followUpPrompt, "override", ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Continue runner error for task {TaskId}", taskId);
+ }
+}
+```
+
+- [ ] **Step 2: Build the full solution**
+
+Run:
+```bash
+dotnet build ClaudeDo.slnx
+```
+
+Expected: may fail due to missing DI registrations (Task 14).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Services/QueueService.cs
+git commit -m "feat(worker): add ContinueTask routing to QueueService"
+```
+
+---
+
+### Task 14: Program.cs — DI registration for new services
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Program.cs:1-47`
+
+- [ ] **Step 1: Register new services**
+
+Add after the existing `builder.Services.AddSingleton();` line:
+
+```csharp
+builder.Services.AddSingleton();
+```
+
+Add after the `builder.Services.AddSingleton();` line:
+
+```csharp
+builder.Services.AddSingleton();
+```
+
+Add after the `builder.Services.AddSingleton();` line (before the QueueService block):
+
+```csharp
+// Agent file management.
+var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
+Directory.CreateDirectory(agentsDir);
+builder.Services.AddSingleton(new AgentFileService(agentsDir));
+```
+
+Also add the using at the top:
+
+```csharp
+using ClaudeDo.Worker.Services;
+```
+
+- [ ] **Step 2: Build the full solution**
+
+Run:
+```bash
+dotnet build ClaudeDo.slnx
+```
+
+Expected: clean build, no errors.
+
+- [ ] **Step 3: Run all tests**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests -v minimal
+```
+
+Expected: all tests pass (some may need FakeClaudeProcess updates from Task 9 step 3).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Program.cs
+git commit -m "feat(worker): register TaskRunRepository, ClaudeArgsBuilder, and AgentFileService in DI"
+```
+
+---
+
+### Task 15: Delete MessageParser + update tests
+
+**Files:**
+- Delete: `src/ClaudeDo.Worker/Runner/MessageParser.cs`
+- Delete: `tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs`
+
+- [ ] **Step 1: Verify no references to MessageParser remain**
+
+Run:
+```bash
+grep -r "MessageParser" src/ tests/ --include="*.cs"
+```
+
+Expected: only hits in the files being deleted (ClaudeProcess no longer calls it after Task 9).
+
+- [ ] **Step 2: Delete the files**
+
+```bash
+rm src/ClaudeDo.Worker/Runner/MessageParser.cs
+rm tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs
+```
+
+- [ ] **Step 3: Build and run all tests**
+
+Run:
+```bash
+dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal
+```
+
+Expected: clean build, all tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "refactor(worker): remove MessageParser (replaced by StreamAnalyzer)"
+```
+
+---
+
+### Task 16: Integration smoke test — Full run with retry
+
+**Files:**
+- Modify: `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs`
+
+- [ ] **Step 1: Add a test for auto-retry flow**
+
+Add a new test to `QueueServiceTests`:
+
+```csharp
+[Fact]
+public async Task RunNow_AutoRetries_On_Failure_With_SessionId()
+{
+ var callCount = 0;
+ var fake = new FakeClaudeProcess((prompt, dir, args, onLine, ct) =>
+ {
+ callCount++;
+ if (callCount == 1)
+ {
+ return Task.FromResult(new RunResult
+ {
+ ExitCode = 1,
+ ErrorMarkdown = "something broke",
+ SessionId = "sess-retry-test",
+ });
+ }
+ return Task.FromResult(new RunResult
+ {
+ ExitCode = 0,
+ ResultMarkdown = "fixed it",
+ SessionId = "sess-retry-test",
+ });
+ });
+
+ // Build a QueueService with the fake and run a task through it.
+ // Verify callCount == 2 (initial + retry).
+ // Verify the task ends as "done".
+ Assert.Equal(2, callCount);
+}
+```
+
+Note: this test skeleton needs the full QueueService wiring that's already in the test file. Follow the existing test patterns for `RunNow` — set up DbFixture, create list + task, construct QueueService with fake dependencies, call `RunNow`, await completion, assert.
+
+- [ ] **Step 2: Run the test**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AutoRetries" -v minimal
+```
+
+Expected: test passes.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
+git commit -m "test(worker): add integration test for auto-retry flow"
+```
+
+---
+
+### Task 17: Update Worker CLAUDE.md
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
+
+- [ ] **Step 1: Update documentation to reflect changes**
+
+Add sections for:
+- New config resolution flow (list_config + task overrides)
+- `task_runs` table and execution tracking
+- Auto-retry and continue flow
+- `ClaudeArgsBuilder`, `StreamAnalyzer` (replacing `MessageParser`)
+- `AgentFileService` and agents directory
+- New hub methods: `ContinueTask`, `GetAgents`, `RefreshAgents`
+- New broadcast: `RunCreated`
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/CLAUDE.md
+git commit -m "docs(worker): update CLAUDE.md with CLI modernization changes"
+```
diff --git a/docs/superpowers/specs/2026-04-14-ui-fixes-design.md b/docs/superpowers/specs/2026-04-14-ui-fixes-design.md
new file mode 100644
index 0000000..bdf7c83
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-14-ui-fixes-design.md
@@ -0,0 +1,216 @@
+# UI Fixes Design Spec
+
+Post-integration fixes for the Worker CLI modernization. Addresses four issues found during first real test.
+
+## Issue 1: Raw NDJSON in Live Log
+
+### Problem
+
+`TaskDetailViewModel.OnTaskMessage` receives raw NDJSON lines from SignalR `TaskMessage` broadcasts and displays them as-is in an `ItemsControl`. Users see JSON like `{"type":"stream_event","event":{...}}` instead of readable output.
+
+### Solution: StreamLineFormatter
+
+New stateful helper class at `ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`.
+
+**Responsibility:** Convert a single raw NDJSON line into human-readable text for display.
+
+**API:**
+
+```csharp
+public sealed class StreamLineFormatter
+{
+ // Returns formatted text to append, or null to skip the line.
+ public string? FormatLine(string ndjsonLine);
+
+ // Reads all lines from an NDJSON log file, formats each, returns complete text.
+ public string FormatFile(string filePath);
+}
+```
+
+**Event mapping (moderate detail level):**
+
+| NDJSON structure | Display output |
+|---|---|
+| `stream_event` → `content_block_delta` → `text_delta` | The delta text content (appended inline) |
+| `stream_event` → `content_block_start` → `tool_use` | `\n[Tool: {name}]\n` |
+| `stream_event` → `content_block_stop` | `\n` (line separator) |
+| `stream_event` → `content_block_delta` → `input_json_delta` | null (skip — tool input noise) |
+| `stream_event` → `message_start` / `message_delta` | null (skip) |
+| `result` | `\n--- Result ---\n{result_text}\n` |
+| `system` with `subtype: api_retry` | `\n[Retrying API call...]\n` |
+| `assistant` | null (skip — content arrives via stream_events) |
+| Malformed JSON / unknown type | Raw line as-is (fallback) |
+
+**State tracking:** The formatter tracks whether the previous line was a text delta to avoid inserting unnecessary newlines between consecutive text chunks.
+
+### Display model change
+
+**Replace `ObservableCollection LiveLines` with `[ObservableProperty] string _liveText = ""`.**
+
+- `OnTaskMessage`: pass line through `_formatter.FormatLine(line)`, append result to `LiveText`
+- Bounding: if `LiveText.Length > 50_000`, trim from the front at the next newline boundary
+- View: replace `ItemsControl` with a read-only `TextBox` (`AcceptsReturn="True"`, `TextWrapping="NoWrap"`, monospace font)
+- Auto-scroll to bottom on text change (code-behind handler on PropertyChanged)
+
+**Rationale:** Text deltas stream per-token. An ItemsControl with hundreds of tiny entries causes UI overhead. A single TextBox with appended text gives a natural terminal feel and better performance.
+
+---
+
+## Issue 2: No Immediate Feedback on Task Start
+
+### Problem
+
+After clicking RunNow, nothing happens visually until the Worker processes the request, updates the DB, and broadcasts `TaskStarted`. The delay (typically <1s, but noticeable) makes the app feel unresponsive.
+
+### Solution: Three-layer optimistic feedback
+
+**Layer 1 — WorkerClient local event:**
+
+Add `event Action? RunNowRequestedEvent` to `WorkerClient`.
+
+In `RunNowAsync(taskId)`: fire `RunNowRequestedEvent(taskId)` **before** calling `_hub.InvokeAsync("RunNow", taskId)`. This gives the UI an instant signal.
+
+**Layer 2 — TaskItemViewModel (list view):**
+
+- Add `[ObservableProperty] bool _isStarting`
+- On `RunNowRequestedEvent` for this task: set `IsStarting = true`, `StatusText = "starting..."`
+- On `TaskStartedEvent` for this task: set `IsStarting = false`
+- `RunNowCommand.CanExecute` also returns false when `IsStarting` (prevents double-click)
+- View: RunNow button disables and shows "Starting..." state
+
+**Layer 3 — TaskDetailViewModel (detail view):**
+
+- Subscribe to `RunNowRequestedEvent` → if current task, set `StatusText = "starting..."`, clear `LiveText`, reset formatter
+- Subscribe to `TaskStartedEvent` → if current task, set `StatusText = "running"`
+- Both are overwritten naturally when `OnTaskUpdated` fires and reloads from DB
+
+**Wiring:** TaskListViewModel subscribes to `WorkerClient.RunNowRequestedEvent` and updates the matching `TaskItemViewModel`. TaskDetailViewModel subscribes directly to WorkerClient events (same pattern as existing TaskMessage/TaskUpdated subscriptions).
+
+---
+
+## Issue 3: Live Output Lost After Completion
+
+### Problem
+
+`LiveText` is in-memory only. Once the task finishes and the user navigates away, the log content is gone. The NDJSON log file exists on disk (at `task.LogPath`) but is never loaded back into the UI.
+
+### Solution: Load from disk on revisit
+
+In `TaskDetailViewModel.LoadAsync`, after loading the task entity:
+
+```csharp
+if (task.LogPath is not null
+ && task.Status is TaskStatus.Done or TaskStatus.Failed
+ && File.Exists(task.LogPath))
+{
+ _formatter = new StreamLineFormatter();
+ LiveText = _formatter.FormatFile(task.LogPath);
+}
+```
+
+**Reuses** `StreamLineFormatter.FormatFile` from Issue 1 — no new infrastructure needed.
+
+**Edge cases:**
+
+- **Task completes while watching:** LiveText already has streamed content. `OnTaskUpdated` triggers `LoadAsync`, which reloads from disk. Same content, re-parsed — no visible disruption.
+- **Log file missing/deleted:** `File.Exists` check handles it. LiveText stays empty. The "Result" field above still shows result markdown from the DB.
+- **Large log files:** Bounded by the same 50,000 char limit as live streaming. `FormatFile` applies the same trim-from-front logic.
+
+---
+
+## Issue 4: Config Editors + Modal Theming
+
+### Problem A: Modal dialogs unstyled
+
+`ListEditorView` and `TaskEditorView` are `` elements with no explicit background. They render as black with white text, not matching the app's green-accented dark theme defined in `App.axaml`.
+
+### Fix: Apply app resource brushes
+
+Both editor `` elements get:
+- `Background="{StaticResource WindowBgBrush}"` (`#1c1e21`)
+- Label TextBlocks: `Foreground="{StaticResource TextSecondaryBrush}"`
+- Save button: `Background="{StaticResource AccentBrush}"` (green accent `#3d9474`)
+- Cancel button: default Fluent dark theme (correct once window bg is set)
+
+### Problem B: No UI for model/prompt/agent config
+
+The backend supports per-list config (`list_config` table) and per-task overrides (`tasks.model`, `tasks.system_prompt`, `tasks.agent_path`), but there are no editor fields for these.
+
+### Solution: Extend existing editors
+
+#### Model selection
+
+ComboBox shows short display labels, mapped to actual model IDs:
+
+| Display | Model ID |
+|---|---|
+| Sonnet | `claude-sonnet-4-6` |
+| Opus | `claude-opus-4-6` |
+| Haiku | `claude-haiku-4-5` |
+
+**Default model:** `Sonnet` (`claude-sonnet-4-6`). Applied in `TaskRunner` config resolution as the final fallback when both task and list config have no model set.
+
+#### WorkerClient — add GetAgents
+
+New methods on `WorkerClient`:
+- `Task> GetAgentsAsync()` — calls hub `GetAgents()`
+- `Task RefreshAgentsAsync()` — calls hub `RefreshAgents()`
+- `record AgentInfo(string Name, string Description, string Path)` — DTO
+
+#### ListEditorViewModel extensions
+
+Three new properties:
+- `[ObservableProperty] string _model` — ComboBox: Sonnet (default for new lists), Opus, Haiku
+- `[ObservableProperty] string? _systemPrompt` — TextBox, multiline, optional
+- `[ObservableProperty] string? _agentPath` — ComboBox populated from `GetAgentsAsync()`, empty = none
+
+`InitForEdit` loads existing config via `ListRepository.GetConfigAsync()`.
+`Save` persists via `ListRepository.SetConfigAsync()`.
+
+#### TaskEditorViewModel extensions
+
+Same three fields, but with inheritance indicators:
+- Model ComboBox: first option `"(list default)"` → maps to null (inherit from list config, which falls back to Sonnet)
+- SystemPrompt: placeholder text `"(inherits from list)"`
+- AgentPath ComboBox: first option `"(list default)"` → maps to null
+
+`InitForEdit` reads from `TaskEntity.Model/SystemPrompt/AgentPath`.
+`Save` writes them back to the entity.
+
+#### View layout
+
+Both editors add an "Agent Config" section below existing fields, separated by a horizontal divider line. Contains: Model dropdown, System Prompt text area, Agent File picker. Always visible (no collapse — only three fields).
+
+Window heights increase to accommodate new fields:
+- ListEditorView: 280 → ~450
+- TaskEditorView: 420 → ~580
+
+---
+
+## Out of Scope
+
+- Agent file creation/editing UI (agents are managed as `.md` files on disk; editors only pick from existing agents)
+- Token usage display in live output
+- Run history viewer (multiple runs per task)
+- Rich text rendering (markdown in result/output)
+
+---
+
+## Files Changed
+
+### New
+- `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
+
+### Modified
+- `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — LiveText, formatter, start feedback, log reload
+- `src/ClaudeDo.Ui/Views/TaskDetailView.axaml` — TextBox replaces ItemsControl, auto-scroll
+- `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` — auto-scroll handler (if needed)
+- `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — IsStarting property
+- `src/ClaudeDo.Ui/Views/TaskListView.axaml` — starting state visual
+- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — RunNowRequestedEvent, GetAgentsAsync, RefreshAgentsAsync, AgentInfo DTO
+- `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` — config fields, agent loading
+- `src/ClaudeDo.Ui/Views/ListEditorView.axaml` — config section, theming
+- `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — config override fields, agent loading
+- `src/ClaudeDo.Ui/Views/TaskEditorView.axaml` — config section, theming
+- `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — wire RunNowRequestedEvent to TaskItemViewModels
+- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — default model fallback to `claude-sonnet-4-6`
diff --git a/docs/superpowers/specs/2026-04-14-worker-cli-modernization-design.md b/docs/superpowers/specs/2026-04-14-worker-cli-modernization-design.md
new file mode 100644
index 0000000..f3fc282
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-14-worker-cli-modernization-design.md
@@ -0,0 +1,514 @@
+# Worker CLI Modernization
+
+**Date:** 2026-04-14
+**Status:** Approved
+**Scope:** ClaudeDo.Worker — CLI invocation, execution tracking, per-task configuration, multi-turn support
+
+## Problem
+
+The Worker currently invokes Claude CLI with hardcoded flags (`-p --output-format stream-json --verbose --dangerously-skip-permissions`). There is no way to configure model, system prompt, or agent per list or task. Execution is single-shot with no retry or follow-up capability. Results are stored as a single markdown blob on the `tasks` row with no structured metadata, token usage, or turn count.
+
+## Goals
+
+1. Per-list configuration (model, system prompt, agent file) with per-task overrides
+2. Execution history — each CLI invocation tracked as its own `task_runs` row
+3. Multi-turn support — manual continue and auto-retry via `--resume`
+4. Structured output alongside markdown via `--json-schema`
+5. Agent file management — filesystem-based `.md` agents with UI to browse/create/edit
+6. Richer stream parsing — token usage, turn count, session ID, retry events
+
+## Non-Goals (Deferred)
+
+- `--bare` mode (forces API key; user relies on OAuth/keychain auth)
+- `--allowedTools` / permission modes (keep `--dangerously-skip-permissions`)
+- Schema migration framework (use `IF NOT EXISTS` / `INSERT OR IGNORE` for additive changes)
+
+---
+
+## 1. Schema Changes
+
+### 1.1 New table: `list_config`
+
+One-to-one with `lists`. Stores per-list defaults for CLI invocation.
+
+```sql
+CREATE TABLE IF NOT EXISTS list_config (
+ list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
+ model TEXT NULL, -- 'opus-4-6' | 'sonnet-4-6' | 'haiku-4-5'
+ system_prompt TEXT NULL, -- appended via --append-system-prompt
+ agent_path TEXT NULL -- path to agent .md file, passed via --agents
+);
+```
+
+### 1.2 New columns on `tasks`
+
+Per-task overrides. All nullable — NULL means "use list default".
+
+```sql
+ALTER TABLE tasks ADD COLUMN model TEXT NULL;
+ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL;
+ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL;
+```
+
+Since schema uses `IF NOT EXISTS` and is re-applied on startup, these are added via `ALTER TABLE ... ADD COLUMN` wrapped in a try/catch (SQLite raises "duplicate column" if already present — safe to ignore).
+
+### 1.3 New table: `task_runs`
+
+One row per CLI invocation. Supports multi-turn and retry tracking.
+
+```sql
+CREATE TABLE IF NOT EXISTS task_runs (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ run_number INTEGER NOT NULL, -- 1, 2, 3... sequential per task
+ session_id TEXT NULL, -- Claude CLI session ID (for --resume)
+ is_retry INTEGER NOT NULL DEFAULT 0, -- 0 = normal/continue, 1 = auto-retry
+ prompt TEXT NOT NULL, -- the prompt sent for this run
+ result_markdown TEXT NULL, -- free-form result from 'result' field
+ structured_output TEXT NULL, -- JSON from 'structured_output' field
+ error_markdown TEXT NULL, -- error output on failure
+ exit_code INTEGER NULL, -- CLI exit code
+ turn_count INTEGER NULL, -- number of agent loop turns
+ tokens_in INTEGER NULL, -- total input tokens
+ tokens_out INTEGER NULL, -- total output tokens
+ log_path TEXT NULL, -- NDJSON log file for this run
+ started_at TIMESTAMP NULL,
+ finished_at TIMESTAMP NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
+```
+
+### 1.4 Denormalized fields on `tasks`
+
+Keep existing `result`, `log_path`, `started_at`, `finished_at` on the `tasks` table. After each run completes, update them with the latest run's values. This preserves backward compatibility for UI queries that read `tasks` directly.
+
+### 1.5 Model validation
+
+Valid model values: `opus-4-6`, `sonnet-4-6`, `haiku-4-5`. Validated at the application layer (repository/service), not via SQL CHECK constraint, to allow easy future additions.
+
+---
+
+## 2. Agent File Management
+
+### 2.1 Directory
+
+Agents live in `~/.todo-app/agents/`. The directory is created on Worker startup if absent.
+
+### 2.2 File format
+
+Standard Claude agent markdown with YAML frontmatter:
+
+```markdown
+---
+name: .NET Developer
+description: Senior .NET developer focused on clean architecture
+---
+
+You are a senior .NET developer. Follow existing project patterns...
+```
+
+### 2.3 AgentFileService
+
+New service in `ClaudeDo.Worker` (not a repository — operates on filesystem, not DB):
+
+| Method | Description |
+|--------|-------------|
+| `ScanAsync()` | Returns `List` — parse frontmatter for name/description from all `*.md` in agents dir |
+| `ReadAsync(string path)` | Full file content |
+| `WriteAsync(string path, string content)` | Create or overwrite |
+| `DeleteAsync(string path)` | Remove file |
+
+### 2.4 AgentInfo DTO
+
+```csharp
+public sealed record AgentInfo(string Name, string Description, string Path);
+```
+
+### 2.5 Discovery
+
+- Worker scans on startup and exposes agents via a new SignalR method `GetAgents()`.
+- UI calls `GetAgents()` to populate dropdowns.
+- A `RefreshAgents()` hub method triggers a re-scan (for after UI creates/edits a file).
+
+---
+
+## 3. CLI Invocation Changes
+
+### 3.1 Current invocation
+
+```
+claude -p --output-format stream-json --verbose --dangerously-skip-permissions
+```
+
+Prompt written to stdin. Single-shot, no config, no structured output.
+
+### 3.2 New invocation
+
+Built dynamically per run by `ClaudeArgsBuilder`:
+
+```
+claude -p
+ --output-format stream-json
+ --verbose
+ --dangerously-skip-permissions
+ --model # if set
+ --append-system-prompt # if set
+ --agents '[{"file":""}]' # if set
+ --json-schema # always
+ --resume # only for multi-turn/retry
+```
+
+### 3.3 Config resolution
+
+```
+resolved_model = task.model ?? list_config.model ?? null (omit --model)
+resolved_prompt = task.system_prompt ?? list_config.system_prompt ?? null (omit --append-system-prompt)
+resolved_agent = task.agent_path ?? list_config.agent_path ?? null (omit --agents)
+```
+
+### 3.4 Structured output schema
+
+Passed via `--json-schema` on every invocation:
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "summary": { "type": "string" },
+ "files_changed": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "commit_type": { "type": "string" }
+ },
+ "required": ["summary"]
+}
+```
+
+The CLI returns this in the `structured_output` field of the JSON result event. The markdown result remains in the `result` field.
+
+### 3.5 ClaudeArgsBuilder
+
+New class, single responsibility for argument construction:
+
+```csharp
+public sealed class ClaudeArgsBuilder
+{
+ // Returns the full argument string for ProcessStartInfo.Arguments
+ public string Build(ClaudeRunConfig config);
+}
+
+public sealed record ClaudeRunConfig(
+ string? Model,
+ string? SystemPrompt,
+ string? AgentPath,
+ string? ResumeSessionId
+);
+```
+
+Testable in isolation — no process spawning, just string building.
+
+---
+
+## 4. Stream Parsing
+
+### 4.1 StreamAnalyzer (replaces MessageParser)
+
+Processes each NDJSON line and accumulates metrics:
+
+| Responsibility | How |
+|---|---|
+| Extract result markdown | Look for `type: "result"`, read `.result` field |
+| Extract structured output | Same event, read `.structured_output` field |
+| Extract session ID | Read `.session_id` from the result event |
+| Count turns | Count events where `.type == "assistant"` |
+| Accumulate tokens | Sum `.usage.input_tokens` and `.usage.output_tokens` from each turn |
+| Track retries | Count `system/api_retry` events (informational logging) |
+
+### 4.2 StreamResult
+
+```csharp
+public sealed class StreamResult
+{
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? SessionId { get; set; }
+ public int TurnCount { get; set; }
+ public int TokensIn { get; set; }
+ public int TokensOut { get; set; }
+ public int ApiRetryCount { get; set; }
+}
+```
+
+### 4.3 Extended RunResult
+
+```csharp
+public sealed class RunResult
+{
+ public required int ExitCode { get; init; }
+ public string? ResultMarkdown { get; init; }
+ public string? ErrorMarkdown { get; init; }
+ public string? StructuredOutputJson { get; init; }
+ public string? SessionId { get; init; }
+ public int TurnCount { get; init; }
+ public int TokensIn { get; init; }
+ public int TokensOut { get; init; }
+
+ public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null;
+}
+```
+
+---
+
+## 5. Multi-Turn & Auto-Retry
+
+### 5.1 Execution flow
+
+```
+Task queued
+ -> Run 1 (run_number=1, is_retry=0)
+ -> Resolve config (list defaults + task overrides)
+ -> Build CLI args (no --resume on first run)
+ -> Spawn claude, stream output, parse via StreamAnalyzer
+ -> Create task_runs row with all metrics
+ -> Update denormalized tasks fields
+
+ If failure (exit_code != 0):
+ -> Auto-retry: Run 2 (run_number=2, is_retry=1)
+ -> Prompt: "The previous attempt failed with:\n\n{error_markdown}\n\nTry again and fix the issues."
+ -> Uses --resume from Run 1
+ -> Same worktree, same config
+ -> Create new task_runs row
+ -> If still fails: mark task Failed, stop
+
+ If success (exit_code == 0):
+ -> Auto-commit in worktree if changes
+ -> Mark task Done
+
+User triggers "Continue" on finished/failed task:
+ -> New run (run_number=N+1, is_retry=0)
+ -> User-provided follow-up prompt
+ -> Uses --resume from last run
+ -> Task status -> Running -> Done/Failed
+```
+
+### 5.2 Rules
+
+- Max 1 auto-retry per task execution (no retry loops)
+- Auto-retry reuses the session via `--resume` (full context of prior failure)
+- Manual continue works on both Done and Failed tasks
+- Each run gets its own log file: `{task_id}_run{N}.ndjson`
+- Worktree commit happens only after a successful run
+- If Run 1 has no session_id (edge case: CLI crashed before producing one), skip auto-retry
+
+### 5.3 Continue via SignalR
+
+New hub method: `ContinueTask(string taskId, string followUpPrompt)` -> returns `string runId`
+
+Validation:
+- Task must exist
+- Task must not be currently running
+- Previous run must have a session_id
+
+---
+
+## 6. TaskRunner Refactoring
+
+### 6.1 Current flow (TaskRunner.RunAsync)
+
+1. Load list, create worktree/sandbox, mark running
+2. Build prompt from title + description
+3. Call `_claude.RunAsync(prompt, dir, logPath, taskId, callback, ct)`
+4. Handle result: commit on success, mark done/failed
+
+### 6.2 New flow
+
+```csharp
+public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
+{
+ // 1. Load list + list_config
+ // 2. Resolve config (merge list_config + task overrides)
+ // 3. Create worktree/sandbox (unchanged)
+ // 4. Execute run (see RunOnceAsync below)
+ // 5. If failed and no prior retry: auto-retry
+ // 6. Final status update
+}
+
+public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
+{
+ // 1. Load task, last run (for session_id)
+ // 2. Mark task running
+ // 3. Execute run with --resume
+ // 4. Commit if success + worktree
+ // 5. Final status update
+}
+
+private async Task RunOnceAsync(
+ TaskEntity task, string slot, string runDir, ClaudeRunConfig config,
+ int runNumber, bool isRetry, string prompt, CancellationToken ct)
+{
+ // 1. Create task_runs row (started_at = now)
+ // 2. Build log path: {task_id}_run{runNumber}.ndjson
+ // 3. Build CLI args via ClaudeArgsBuilder
+ // 4. Spawn ClaudeProcess
+ // 5. Stream lines to LogWriter + StreamAnalyzer + HubBroadcaster
+ // 6. Build RunResult from StreamAnalyzer
+ // 7. Update task_runs row (finished_at, metrics, result)
+ // 8. Update denormalized tasks fields
+ // 9. Return RunResult
+}
+```
+
+### 6.3 ClaudeProcess changes
+
+Simplified — receives pre-built args, no longer constructs its own:
+
+```csharp
+public async Task RunAsync(
+ string arguments, // pre-built by ClaudeArgsBuilder
+ string prompt, // written to stdin
+ string workingDirectory,
+ Func onStdoutLine,
+ CancellationToken ct)
+```
+
+The `StreamAnalyzer` instance is owned by the caller (TaskRunner), not ClaudeProcess. ClaudeProcess just feeds lines via the callback.
+
+---
+
+## 7. Repository Changes
+
+### 7.1 New: TaskRunRepository
+
+| Method | Description |
+|--------|-------------|
+| `AddAsync(TaskRunEntity)` | Insert new run |
+| `UpdateAsync(TaskRunEntity)` | Update after completion |
+| `GetByTaskIdAsync(string taskId)` | All runs for a task, ordered by run_number |
+| `GetLatestByTaskIdAsync(string taskId)` | Most recent run (for session_id lookup) |
+| `GetByIdAsync(string runId)` | Single run |
+
+### 7.2 Extended: ListRepository
+
+| Method | Description |
+|--------|-------------|
+| `GetConfigAsync(string listId)` | Returns `ListConfigEntity?` |
+| `SetConfigAsync(ListConfigEntity)` | Upsert via INSERT OR REPLACE |
+
+### 7.3 New models
+
+```csharp
+public sealed class TaskRunEntity
+{
+ public required string Id { get; init; }
+ public required string TaskId { get; init; }
+ public required int RunNumber { get; init; }
+ public string? SessionId { get; set; }
+ public required bool IsRetry { get; init; }
+ public required string Prompt { get; init; }
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? ErrorMarkdown { get; set; }
+ public int? ExitCode { get; set; }
+ public int? TurnCount { get; set; }
+ public int? TokensIn { get; set; }
+ public int? TokensOut { get; set; }
+ public string? LogPath { get; set; }
+ public DateTime? StartedAt { get; set; }
+ public DateTime? FinishedAt { get; set; }
+}
+
+public sealed class ListConfigEntity
+{
+ public required string ListId { get; init; }
+ public string? Model { get; set; }
+ public string? SystemPrompt { get; set; }
+ public string? AgentPath { get; set; }
+}
+```
+
+---
+
+## 8. SignalR Hub Changes
+
+### 8.1 New server methods
+
+| Method | Description |
+|--------|-------------|
+| `ContinueTask(string taskId, string followUpPrompt)` | Trigger follow-up run. Returns `string runId`. Throws if running or no session. |
+| `GetAgents()` | Returns `List` from AgentFileService scan |
+| `RefreshAgents()` | Re-scan agents directory |
+
+### 8.2 Updated broadcasts
+
+| Event | Change |
+|-------|--------|
+| `TaskStarted(slot, taskId, runId, runNumber, startedAt)` | Added `runId`, `runNumber` |
+| `TaskFinished(slot, taskId, runId, status, finishedAt)` | Added `runId` |
+| `TaskMessage(taskId, runId, ndjsonLine)` | Added `runId` |
+| `RunCreated(taskId, runId, runNumber, isRetry)` | New — signals retry/continue started |
+
+### 8.3 Unchanged
+
+`Ping`, `GetActive`, `CancelTask`, `WakeQueue`, `WorktreeUpdated`, `TaskUpdated` — no changes.
+
+---
+
+## 9. File Structure (New/Changed)
+
+```
+src/ClaudeDo.Worker/
+ Runner/
+ ClaudeArgsBuilder.cs NEW — CLI argument construction
+ StreamAnalyzer.cs NEW — replaces MessageParser
+ StreamResult.cs NEW — accumulated stream metrics
+ RunResult.cs CHANGED — extended with tokens, turns, session_id
+ ClaudeProcess.cs CHANGED — simplified, takes pre-built args
+ TaskRunner.cs CHANGED — retry/continue logic, config resolution
+ MessageParser.cs DELETED — replaced by StreamAnalyzer
+ Services/
+ AgentFileService.cs NEW — filesystem agent management
+
+src/ClaudeDo.Data/
+ Models/
+ TaskRunEntity.cs NEW
+ ListConfigEntity.cs NEW
+ AgentInfo.cs NEW — DTO (name, description, path)
+ Repositories/
+ TaskRunRepository.cs NEW
+ ListRepository.cs CHANGED — GetConfigAsync, SetConfigAsync
+
+schema/
+ schema.sql CHANGED — list_config table, task_runs table, tasks columns
+```
+
+---
+
+## 10. Testing Strategy
+
+### 10.1 Unit tests (new)
+
+| Test class | Covers |
+|------------|--------|
+| `ClaudeArgsBuilderTests` | Arg construction with all config combos, omitted flags for null values |
+| `StreamAnalyzerTests` | Turn counting, token accumulation, result extraction, session_id, retry events, malformed input |
+| `AgentFileServiceTests` | Scan, frontmatter parsing, read/write/delete, missing directory handling |
+
+### 10.2 Unit tests (updated)
+
+| Test class | Changes |
+|------------|---------|
+| `TaskRunnerTests` | New: auto-retry flow, continue flow, config resolution |
+| `QueueServiceTests` | New: continue task routing |
+
+### 10.3 Integration tests (new)
+
+| Test class | Covers |
+|------------|--------|
+| `TaskRunRepositoryTests` | CRUD, ordering, latest-by-task queries |
+| `ListRepositoryConfigTests` | GetConfig, SetConfig upsert behavior |
+
+### 10.4 Existing tests (MessageParserTests)
+
+Removed along with `MessageParser`. Equivalent coverage moves to `StreamAnalyzerTests`.
diff --git a/schema/schema.sql b/schema/schema.sql
index 83a09e2..5f65955 100644
--- a/schema/schema.sql
+++ b/schema/schema.sql
@@ -46,6 +46,13 @@ CREATE TABLE IF NOT EXISTS task_tags (
PRIMARY KEY (task_id, tag_id)
);
+CREATE TABLE IF NOT EXISTS list_config (
+ list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
+ model TEXT NULL,
+ system_prompt TEXT NULL,
+ agent_path TEXT NULL
+);
+
CREATE TABLE IF NOT EXISTS worktrees (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
path TEXT NOT NULL,
@@ -57,6 +64,27 @@ CREATE TABLE IF NOT EXISTS worktrees (
created_at TIMESTAMP NOT NULL
);
+CREATE TABLE IF NOT EXISTS task_runs (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ run_number INTEGER NOT NULL,
+ session_id TEXT NULL,
+ is_retry INTEGER NOT NULL DEFAULT 0,
+ prompt TEXT NOT NULL,
+ result_markdown TEXT NULL,
+ structured_output TEXT NULL,
+ error_markdown TEXT NULL,
+ exit_code INTEGER NULL,
+ turn_count INTEGER NULL,
+ tokens_in INTEGER NULL,
+ tokens_out INTEGER NULL,
+ log_path TEXT NULL,
+ started_at TIMESTAMP NULL,
+ finished_at TIMESTAMP NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
+
-- Seed: minimal tag set (ignored if already present)
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
INSERT OR IGNORE INTO tags (name) VALUES ('manual');
diff --git a/src/ClaudeDo.App/CLAUDE.md b/src/ClaudeDo.App/CLAUDE.md
new file mode 100644
index 0000000..a752d3a
--- /dev/null
+++ b/src/ClaudeDo.App/CLAUDE.md
@@ -0,0 +1,29 @@
+# ClaudeDo.App
+
+Desktop entry point for the ClaudeDo application. Configures DI, initializes the database, and launches the Avalonia window.
+
+## Responsibility
+
+- `Program.cs` — STA thread, DI container registration (repositories, services, viewmodels), schema init, Avalonia builder
+- `App.axaml` / `App.axaml.cs` — Avalonia application lifecycle, main window creation, static `ServiceProvider` accessor
+- `ViewLocator.cs` — reflection-based IDataTemplate that maps ViewModels to Views by naming convention
+
+## Dependencies
+
+- Avalonia 12.0.0 (Desktop, Fluent theme, Inter fonts)
+- CommunityToolkit.Mvvm 8.4.1
+- Microsoft.Extensions.DependencyInjection 8.0.1
+- Microsoft.AspNetCore.SignalR.Client 8.0.11
+- Microsoft.Data.Sqlite 8.0.11
+- Project references: ClaudeDo.Data, ClaudeDo.Ui
+
+## DI Registration Pattern
+
+- **Singletons**: SqliteConnectionFactory, all Repositories, WorkerClient, MainWindowViewModel, TaskListViewModel, TaskDetailViewModel, StatusBarViewModel
+- **Transients**: TaskEditorViewModel, ListEditorViewModel (created per dialog)
+
+## Notes
+
+- This project owns the composition root — all wiring happens here
+- ViewLocator resolves `FooViewModel` -> `FooView` by replacing "ViewModel" with "View" in the type name
+- AvaloniaUI diagnostics are conditionally included (DEBUG only)
diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md
new file mode 100644
index 0000000..6720add
--- /dev/null
+++ b/src/ClaudeDo.Data/CLAUDE.md
@@ -0,0 +1,41 @@
+# ClaudeDo.Data
+
+Shared data layer: models, repositories, SQLite infrastructure, and git operations.
+
+## Models
+
+- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType
+- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
+- **TagEntity** — Id (autoincrement), Name (unique)
+- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
+
+## Repositories
+
+All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each method opens its own connection — no Unit of Work.
+
+- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`
+- **ListRepository** — CRUD, tag junction management
+- **TagRepository** — `GetOrCreateAsync` (idempotent)
+- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
+
+## Infrastructure
+
+- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA
+- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE)
+- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
+- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
+
+## Git
+
+- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo
+
+## Schema
+
+6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. See `schema/schema.sql`. Seed data: tags "agent" and "manual".
+
+## Conventions
+
+- Enum <-> string mapping via explicit `ToDb()`/`FromDb()` static methods on each enum
+- Primary keys are `init`-only strings (GUIDs assigned at creation)
+- Nullable fields use `DBNull.Value` checks
+- All methods are async with CancellationToken where applicable
diff --git a/src/ClaudeDo.Data/Models/AgentInfo.cs b/src/ClaudeDo.Data/Models/AgentInfo.cs
new file mode 100644
index 0000000..9a3b7a2
--- /dev/null
+++ b/src/ClaudeDo.Data/Models/AgentInfo.cs
@@ -0,0 +1,3 @@
+namespace ClaudeDo.Data.Models;
+
+public sealed record AgentInfo(string Name, string Description, string Path);
diff --git a/src/ClaudeDo.Data/Models/ListConfigEntity.cs b/src/ClaudeDo.Data/Models/ListConfigEntity.cs
new file mode 100644
index 0000000..90ba533
--- /dev/null
+++ b/src/ClaudeDo.Data/Models/ListConfigEntity.cs
@@ -0,0 +1,9 @@
+namespace ClaudeDo.Data.Models;
+
+public sealed class ListConfigEntity
+{
+ public required string ListId { get; init; }
+ public string? Model { get; set; }
+ public string? SystemPrompt { get; set; }
+ public string? AgentPath { get; set; }
+}
diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs
index eef77b4..55af098 100644
--- a/src/ClaudeDo.Data/Models/TaskEntity.cs
+++ b/src/ClaudeDo.Data/Models/TaskEntity.cs
@@ -23,4 +23,7 @@ public sealed class TaskEntity
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
public string CommitType { get; set; } = "chore";
+ public string? Model { get; set; }
+ public string? SystemPrompt { get; set; }
+ public string? AgentPath { get; set; }
}
diff --git a/src/ClaudeDo.Data/Models/TaskRunEntity.cs b/src/ClaudeDo.Data/Models/TaskRunEntity.cs
new file mode 100644
index 0000000..65dc3d2
--- /dev/null
+++ b/src/ClaudeDo.Data/Models/TaskRunEntity.cs
@@ -0,0 +1,21 @@
+namespace ClaudeDo.Data.Models;
+
+public sealed class TaskRunEntity
+{
+ public required string Id { get; init; }
+ public required string TaskId { get; init; }
+ public required int RunNumber { get; init; }
+ public string? SessionId { get; set; }
+ public required bool IsRetry { get; init; }
+ public required string Prompt { get; init; }
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? ErrorMarkdown { get; set; }
+ public int? ExitCode { get; set; }
+ public int? TurnCount { get; set; }
+ public int? TokensIn { get; set; }
+ public int? TokensOut { get; set; }
+ public string? LogPath { get; set; }
+ public DateTime? StartedAt { get; set; }
+ public DateTime? FinishedAt { get; set; }
+}
diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs
index eb185ae..ff0a639 100644
--- a/src/ClaudeDo.Data/Repositories/ListRepository.cs
+++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs
@@ -113,6 +113,39 @@ public sealed class ListRepository
await cmd.ExecuteNonQueryAsync(ct);
}
+ public async Task GetConfigAsync(string listId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id";
+ cmd.Parameters.AddWithValue("@list_id", listId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return new ListConfigEntity
+ {
+ ListId = reader.GetString(0),
+ Model = reader.IsDBNull(1) ? null : reader.GetString(1),
+ SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2),
+ AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3),
+ };
+ }
+
+ public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path)
+ VALUES (@list_id, @model, @system_prompt, @agent_path)
+ """;
+ cmd.Parameters.AddWithValue("@list_id", entity.ListId);
+ cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
private static ListEntity ReadList(SqliteDataReader reader) => new()
{
Id = reader.GetString(0),
diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs
index 8db5b23..ba62735 100644
--- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs
+++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs
@@ -42,9 +42,11 @@ public sealed class TaskRepository
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
- result, log_path, created_at, started_at, finished_at, commit_type)
+ result, log_path, created_at, started_at, finished_at, commit_type,
+ model, system_prompt, agent_path)
VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
- @result, @log_path, @created_at, @started_at, @finished_at, @commit_type)
+ @result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
+ @model, @system_prompt, @agent_path)
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
@@ -58,7 +60,8 @@ public sealed class TaskRepository
UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
status = @status, scheduled_for = @scheduled_for, result = @result,
log_path = @log_path, started_at = @started_at,
- finished_at = @finished_at, commit_type = @commit_type
+ finished_at = @finished_at, commit_type = @commit_type,
+ model = @model, system_prompt = @system_prompt, agent_path = @agent_path
WHERE id = @id
""";
BindTask(cmd, entity);
@@ -78,7 +81,7 @@ public sealed class TaskRepository
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
- cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type FROM tasks WHERE id = @id";
+ cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
@@ -90,7 +93,7 @@ public sealed class TaskRepository
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
- cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type FROM tasks WHERE list_id = @list_id ORDER BY created_at";
+ cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE list_id = @list_id ORDER BY created_at";
cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
@@ -175,7 +178,8 @@ public sealed class TaskRepository
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for,
- t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type
+ t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type,
+ t.model, t.system_prompt, t.agent_path
FROM tasks t
WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
@@ -277,6 +281,9 @@ public sealed class TaskRepository
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
+ cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
}
private static TaskEntity ReadTask(SqliteDataReader r) => new()
@@ -293,6 +300,9 @@ public sealed class TaskRepository
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
CommitType = r.GetString(11),
+ Model = r.IsDBNull(12) ? null : r.GetString(12),
+ SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
+ AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
};
#endregion
diff --git a/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs
new file mode 100644
index 0000000..a635113
--- /dev/null
+++ b/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs
@@ -0,0 +1,139 @@
+using System.Globalization;
+using ClaudeDo.Data.Models;
+using Microsoft.Data.Sqlite;
+
+namespace ClaudeDo.Data.Repositories;
+
+public sealed class TaskRunRepository
+{
+ private readonly SqliteConnectionFactory _factory;
+
+ public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory;
+
+ public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
+ result_markdown, structured_output, error_markdown, exit_code,
+ turn_count, tokens_in, tokens_out, log_path, started_at, finished_at)
+ VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
+ @result_markdown, @structured_output, @error_markdown, @exit_code,
+ @turn_count, @tokens_in, @tokens_out, @log_path, @started_at, @finished_at)
+ """;
+ BindRun(cmd, entity);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE task_runs SET session_id = @session_id,
+ result_markdown = @result_markdown,
+ structured_output = @structured_output,
+ error_markdown = @error_markdown,
+ exit_code = @exit_code,
+ turn_count = @turn_count,
+ tokens_in = @tokens_in,
+ tokens_out = @tokens_out,
+ finished_at = @finished_at
+ WHERE id = @id
+ """;
+ cmd.Parameters.AddWithValue("@id", entity.Id);
+ cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task GetByIdAsync(string runId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id";
+ cmd.Parameters.AddWithValue("@id", runId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return ReadRun(reader);
+ }
+
+ public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number";
+ cmd.Parameters.AddWithValue("@task_id", taskId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ var result = new List();
+ while (await reader.ReadAsync(ct))
+ result.Add(ReadRun(reader));
+ return result;
+ }
+
+ public async Task GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1";
+ cmd.Parameters.AddWithValue("@task_id", taskId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return ReadRun(reader);
+ }
+
+ #region Helpers
+
+ private static void BindRun(SqliteCommand cmd, TaskRunEntity e)
+ {
+ cmd.Parameters.AddWithValue("@id", e.Id);
+ cmd.Parameters.AddWithValue("@task_id", e.TaskId);
+ cmd.Parameters.AddWithValue("@run_number", e.RunNumber);
+ cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0);
+ cmd.Parameters.AddWithValue("@prompt", e.Prompt);
+ cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
+ }
+
+ private static TaskRunEntity ReadRun(SqliteDataReader r) => new()
+ {
+ Id = r.GetString(0),
+ TaskId = r.GetString(1),
+ RunNumber = r.GetInt32(2),
+ SessionId = r.IsDBNull(3) ? null : r.GetString(3),
+ IsRetry = r.GetInt32(4) != 0,
+ Prompt = r.GetString(5),
+ ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6),
+ StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7),
+ ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8),
+ ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9),
+ TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10),
+ TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11),
+ TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12),
+ LogPath = r.IsDBNull(13) ? null : r.GetString(13),
+ StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14), null, DateTimeStyles.RoundtripKind),
+ FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15), null, DateTimeStyles.RoundtripKind),
+ };
+
+ #endregion
+}
diff --git a/src/ClaudeDo.Data/SchemaInitializer.cs b/src/ClaudeDo.Data/SchemaInitializer.cs
index 17eff67..65e4806 100644
--- a/src/ClaudeDo.Data/SchemaInitializer.cs
+++ b/src/ClaudeDo.Data/SchemaInitializer.cs
@@ -26,6 +26,32 @@ public static class SchemaInitializer
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
tx.Commit();
+
+ ApplyMigrations(conn);
+ }
+
+ private static void ApplyMigrations(SqliteConnection conn)
+ {
+ string[] alterStatements =
+ [
+ "ALTER TABLE tasks ADD COLUMN model TEXT NULL",
+ "ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
+ "ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
+ ];
+
+ foreach (var sql in alterStatements)
+ {
+ try
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.ExecuteNonQuery();
+ }
+ catch (SqliteException ex) when (ex.SqliteErrorCode == 1)
+ {
+ // Column already exists — safe to ignore.
+ }
+ }
}
private static string LoadScript()
diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md
new file mode 100644
index 0000000..127ccbc
--- /dev/null
+++ b/src/ClaudeDo.Ui/CLAUDE.md
@@ -0,0 +1,49 @@
+# ClaudeDo.Ui
+
+Avalonia UI layer: views, viewmodels, converters, and the SignalR client.
+
+## Pattern
+
+MVVM with CommunityToolkit.Mvvm source generators:
+- `[ObservableProperty]` for bindable properties
+- `[RelayCommand]` for commands (supports async and CanExecute)
+- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
+
+## Views
+
+- **MainWindow** — 3-column DockPanel layout (lists | tasks | detail) with GridSplitter, status bar at bottom
+- **TaskListView** — ListBox of tasks with add/edit/delete toolbar
+- **TaskDetailView** — Task info, live log output, worktree section (merge/keep/discard)
+- **TaskEditorView** — Modal dialog for task create/edit
+- **ListEditorView** — Modal dialog for list create/edit
+- **StatusBarView** — Connection status indicator, active task display
+
+All views use compiled bindings (`x:DataType`).
+
+## ViewModels
+
+- **MainWindowViewModel** — root coordinator; manages list collection, selected list, dialog creation via `Func` factories
+- **TaskListViewModel** — manages task collection for selected list; handles CRUD, "Run Now"
+- **TaskDetailViewModel** — displays task details, streams live log, controls worktree operations
+- **TaskItemViewModel** / **ListItemViewModel** — lightweight display VMs
+- **TaskEditorViewModel** / **ListEditorViewModel** — dialog VMs with validation
+- **StatusBarViewModel** — connection state and active tasks
+
+## Services
+
+- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated
+
+## Converters
+
+- **StatusColorConverter** — task status string -> color (Queued=Blue, Running=Orange, Done=Green, Failed=Red, Manual=Gray)
+- **ConnectionColorConverter** — connection state -> color (Online=Green, Offline=Red)
+
+## Dialog Pattern
+
+Editor dialogs use `TaskCompletionSource` — the dialog sets the result on save/cancel, and the caller awaits the TCS.
+
+## Notes
+
+- Context menus are on both list items and task items
+- Right-click selects the item before showing the context menu
+- "Run Now" CanExecute re-evaluates when worker connection state changes
diff --git a/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs b/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
new file mode 100644
index 0000000..267e8c3
--- /dev/null
+++ b/src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
@@ -0,0 +1,115 @@
+using System.Text;
+using System.Text.Json;
+
+namespace ClaudeDo.Ui.Helpers;
+
+public class StreamLineFormatter
+{
+ private const int MaxLength = 50_000;
+
+ public string? FormatLine(string line)
+ {
+ JsonDocument doc;
+ try
+ {
+ doc = JsonDocument.Parse(line);
+ }
+ catch (JsonException)
+ {
+ return line;
+ }
+
+ using (doc)
+ {
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("type", out var typeProp))
+ return null;
+
+ var type = typeProp.GetString();
+
+ switch (type)
+ {
+ case "stream_event":
+ return FormatStreamEvent(root);
+
+ case "result":
+ if (root.TryGetProperty("result", out var resultProp))
+ return $"\n--- Result ---\n{resultProp.GetString()}\n";
+ return null;
+
+ case "system":
+ if (root.TryGetProperty("subtype", out var subtypeProp) &&
+ subtypeProp.GetString() == "api_retry")
+ return "\n[Retrying API call...]\n";
+ return null;
+
+ default:
+ return null;
+ }
+ }
+ }
+
+ private static string? FormatStreamEvent(JsonElement root)
+ {
+ if (!root.TryGetProperty("event", out var ev))
+ return null;
+ if (!ev.TryGetProperty("type", out var evTypeProp))
+ return null;
+
+ var evType = evTypeProp.GetString();
+
+ switch (evType)
+ {
+ case "content_block_delta":
+ if (!ev.TryGetProperty("delta", out var delta))
+ return null;
+ if (!delta.TryGetProperty("type", out var deltaTypeProp))
+ return null;
+ var deltaType = deltaTypeProp.GetString();
+ if (deltaType == "text_delta")
+ {
+ return delta.TryGetProperty("text", out var textProp)
+ ? textProp.GetString()
+ : null;
+ }
+ return null; // input_json_delta and others → skip
+
+ case "content_block_stop":
+ return "\n";
+
+ case "content_block_start":
+ if (!ev.TryGetProperty("content_block", out var cb))
+ return null;
+ if (cb.TryGetProperty("type", out var cbTypeProp) &&
+ cbTypeProp.GetString() == "tool_use" &&
+ cb.TryGetProperty("name", out var nameProp))
+ return $"\n[Tool: {nameProp.GetString()}]\n";
+ return null;
+
+ default:
+ return null; // message_start, message_delta, etc.
+ }
+ }
+
+ public string FormatFile(string filePath)
+ {
+ var sb = new StringBuilder();
+ foreach (var line in File.ReadLines(filePath))
+ {
+ var formatted = FormatLine(line);
+ if (formatted is not null)
+ sb.Append(formatted);
+ }
+ return Trim(sb.ToString());
+ }
+
+ public static string Trim(string text)
+ {
+ if (text.Length <= MaxLength) return text;
+ var trimStart = text.Length - MaxLength;
+ var newlineAfter = text.IndexOf('\n', trimStart);
+ if (newlineAfter >= 0 && newlineAfter < trimStart + 200)
+ trimStart = newlineAfter + 1;
+ return text[trimStart..];
+ }
+}
diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs
index 05458a9..ace7a7a 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
+using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR.Client;
@@ -42,6 +43,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public event Action? TaskMessageEvent;
public event Action? TaskUpdatedEvent;
public event Action? WorktreeUpdatedEvent;
+ public event Action? RunNowRequestedEvent;
public WorkerClient(string signalRUrl)
{
@@ -162,6 +164,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public async Task RunNowAsync(string taskId)
{
+ RunNowRequestedEvent?.Invoke(taskId);
await _hub.InvokeAsync("RunNow", taskId);
}
@@ -175,6 +178,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("WakeQueue");
}
+ public async Task> GetAgentsAsync()
+ {
+ try
+ {
+ var agents = await _hub.InvokeAsync>("GetAgents");
+ return agents ?? [];
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ public async Task RefreshAgentsAsync()
+ {
+ await _hub.InvokeAsync("RefreshAgents");
+ }
+
private async Task SeedActiveTasksAsync()
{
try
@@ -200,7 +221,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.DisposeAsync();
}
- // DTO for deserializing the GetActive response
+ // DTOs for deserializing hub responses
private sealed class ActiveTaskDto
{
public string Slot { get; set; } = "";
diff --git a/src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
index 638f68a..2898aad 100644
--- a/src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
@@ -1,6 +1,8 @@
using ClaudeDo.Data.Models;
+using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using AgentInfo = ClaudeDo.Data.Models.AgentInfo;
namespace ClaudeDo.Ui.ViewModels;
@@ -11,6 +13,11 @@ public partial class ListEditorViewModel : ViewModelBase
[ObservableProperty] private string _defaultCommitType = "chore";
[ObservableProperty] private string _windowTitle = "New List";
+ // Config fields
+ [ObservableProperty] private string _model = "Sonnet";
+ [ObservableProperty] private string? _systemPrompt;
+ [ObservableProperty] private AgentInfo? _selectedAgent;
+
private string? _editId;
private DateTime _createdAt;
private TaskCompletionSource _tcs = new();
@@ -20,6 +27,31 @@ public partial class ListEditorViewModel : ViewModelBase
public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
+ public static string[] ModelDisplayNames { get; } = ["Sonnet", "Opus", "Haiku"];
+
+ private static readonly Dictionary ModelToId = new()
+ {
+ ["Sonnet"] = "claude-sonnet-4-6",
+ ["Opus"] = "claude-opus-4-6",
+ ["Haiku"] = "claude-haiku-4-5",
+ };
+
+ private static readonly Dictionary IdToModel =
+ ModelToId.ToDictionary(kv => kv.Value, kv => kv.Key);
+
+ public static string ModelIdToDisplay(string? modelId) =>
+ modelId is not null && IdToModel.TryGetValue(modelId, out var display) ? display : "Sonnet";
+
+ public static string? ModelDisplayToId(string display) =>
+ ModelToId.TryGetValue(display, out var id) ? id : null;
+
+ public List AvailableAgents { get; set; } = [];
+
+ public async Task LoadAgentsAsync(WorkerClient worker)
+ {
+ AvailableAgents = await worker.GetAgentsAsync();
+ }
+
public void InitForCreate()
{
_editId = null;
@@ -27,7 +59,7 @@ public partial class ListEditorViewModel : ViewModelBase
WindowTitle = "New List";
}
- public void InitForEdit(ListEntity entity)
+ public void InitForEdit(ListEntity entity, ListConfigEntity? config)
{
_editId = entity.Id;
_createdAt = entity.CreatedAt;
@@ -35,6 +67,28 @@ public partial class ListEditorViewModel : ViewModelBase
WorkingDir = entity.WorkingDir;
DefaultCommitType = entity.DefaultCommitType;
WindowTitle = $"Edit List: {entity.Name}";
+
+ if (config is not null)
+ {
+ Model = ModelIdToDisplay(config.Model);
+ SystemPrompt = config.SystemPrompt;
+ SelectedAgent = AvailableAgents.FirstOrDefault(a => a.Path == config.AgentPath);
+ }
+ }
+
+ public ListConfigEntity? BuildConfig(string listId)
+ {
+ var modelId = ModelDisplayToId(Model);
+ if (modelId is null && SystemPrompt is null && SelectedAgent is null)
+ return null;
+
+ return new ListConfigEntity
+ {
+ ListId = listId,
+ Model = modelId,
+ SystemPrompt = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt.Trim(),
+ AgentPath = SelectedAgent?.Path,
+ };
}
[RelayCommand]
@@ -60,10 +114,11 @@ public partial class ListEditorViewModel : ViewModelBase
RequestClose?.Invoke();
}
- ///
- /// Called by the view to await the editor result.
- /// Returns the entity to persist or null if cancelled.
- ///
+ public void OnWindowClosed()
+ {
+ _tcs.TrySetResult(null);
+ }
+
public Task ShowAndWaitAsync()
{
_tcs = new TaskCompletionSource();
diff --git a/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs
index 4b3d573..4309d43 100644
--- a/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs
@@ -78,10 +78,12 @@ public partial class MainWindowViewModel : ViewModelBase
private async Task AddList()
{
var editor = _listEditorFactory();
+ await editor.LoadAgentsAsync(_worker);
editor.InitForCreate();
var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
+ window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync();
@@ -90,6 +92,9 @@ public partial class MainWindowViewModel : ViewModelBase
try
{
await _listRepo.AddAsync(entity);
+ var configEntity = editor.BuildConfig(entity.Id);
+ if (configEntity is not null)
+ await _listRepo.SetConfigAsync(configEntity);
Lists.Add(new ListItemViewModel(entity));
}
catch (Exception ex)
@@ -105,11 +110,14 @@ public partial class MainWindowViewModel : ViewModelBase
var existing = await _listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return;
+ var config = await _listRepo.GetConfigAsync(existing.Id);
var editor = _listEditorFactory();
- editor.InitForEdit(existing);
+ await editor.LoadAgentsAsync(_worker);
+ editor.InitForEdit(existing, config);
var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
+ window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync();
@@ -118,6 +126,9 @@ public partial class MainWindowViewModel : ViewModelBase
try
{
await _listRepo.UpdateAsync(entity);
+ var configEntity = editor.BuildConfig(entity.Id);
+ if (configEntity is not null)
+ await _listRepo.SetConfigAsync(configEntity);
SelectedList.Name = entity.Name;
SelectedList.WorkingDir = entity.WorkingDir;
SelectedList.DefaultCommitType = entity.DefaultCommitType;
diff --git a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
index c6959f6..63b30c9 100644
--- a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
@@ -1,8 +1,11 @@
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.Diagnostics;
+using System.IO;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
+using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -37,14 +40,14 @@ public partial class TaskDetailViewModel : ViewModelBase
[ObservableProperty] private string _worktreeState = "";
// Live stream
- public ObservableCollection LiveLines { get; } = new();
+ [ObservableProperty] private string _liveText = "";
+ private StreamLineFormatter _formatter = new();
public ObservableCollection Tags { get; } = new();
[ObservableProperty] private string _newTagInput = "";
private string? _taskId;
private string? _listId;
private bool _isLoading;
- private const int MaxLiveLines = 500;
public event Action? TaskChanged;
@@ -61,12 +64,15 @@ public partial class TaskDetailViewModel : ViewModelBase
worker.TaskMessageEvent += OnTaskMessage;
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
worker.TaskUpdatedEvent += OnTaskUpdated;
+ worker.RunNowRequestedEvent += OnRunNowRequested;
+ worker.TaskStartedEvent += OnTaskStarted;
}
public async Task LoadAsync(string taskId)
{
_taskId = taskId;
- LiveLines.Clear();
+ LiveText = "";
+ _formatter = new StreamLineFormatter();
var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null) return;
@@ -79,6 +85,13 @@ public partial class TaskDetailViewModel : ViewModelBase
Description = task.Description;
Result = task.Result;
LogPath = task.LogPath;
+ if (task.LogPath is not null
+ && task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
+ && File.Exists(task.LogPath))
+ {
+ _formatter = new StreamLineFormatter();
+ LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath));
+ }
StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString();
CommitType = task.CommitType;
@@ -152,7 +165,8 @@ public partial class TaskDetailViewModel : ViewModelBase
LogPath = null;
StatusText = "";
HasWorktree = false;
- LiveLines.Clear();
+ LiveText = "";
+ _formatter = new StreamLineFormatter();
Tags.Clear();
NewTagInput = "";
StatusChoice = "Manual";
@@ -259,9 +273,27 @@ public partial class TaskDetailViewModel : ViewModelBase
private void OnTaskMessage(string taskId, string line)
{
if (taskId != _taskId) return;
- if (LiveLines.Count >= MaxLiveLines)
- LiveLines.RemoveAt(0);
- LiveLines.Add(line);
+ var formatted = _formatter.FormatLine(line);
+ if (formatted is not null)
+ {
+ LiveText += formatted;
+ if (LiveText.Length > 50_000)
+ LiveText = StreamLineFormatter.Trim(LiveText);
+ }
+ }
+
+ private void OnRunNowRequested(string taskId)
+ {
+ if (taskId != _taskId) return;
+ StatusText = "starting...";
+ LiveText = "";
+ _formatter = new StreamLineFormatter();
+ }
+
+ private void OnTaskStarted(string slot, string taskId, DateTime startedAt)
+ {
+ if (taskId != _taskId) return;
+ StatusText = "running";
}
private async void OnWorktreeUpdated(string taskId)
diff --git a/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs
index 81ee756..b388e0b 100644
--- a/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs
@@ -1,4 +1,5 @@
using ClaudeDo.Data.Models;
+using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -13,6 +14,10 @@ public partial class TaskEditorViewModel : ViewModelBase
[ObservableProperty] private string _statusChoice = "manual";
[ObservableProperty] private string _tagsInput = "";
[ObservableProperty] private string _windowTitle = "New Task";
+ [ObservableProperty] private string _modelChoice = "(list default)";
+ [ObservableProperty] private string? _systemPromptOverride;
+ [ObservableProperty] private AgentInfo? _selectedAgent;
+ public List AvailableAgents { get; set; } = [];
private string? _editId;
private string _listId = "";
@@ -21,12 +26,19 @@ public partial class TaskEditorViewModel : ViewModelBase
public event Action? RequestClose;
+ public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
+
public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
public static string[] StatusChoices { get; } =
["manual", "queued"];
+ public async Task LoadAgentsAsync(WorkerClient worker)
+ {
+ AvailableAgents = await worker.GetAgentsAsync();
+ }
+
public IReadOnlyList SelectedTagNames =>
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct()
@@ -56,6 +68,13 @@ public partial class TaskEditorViewModel : ViewModelBase
_ => entity.Status.ToString().ToLowerInvariant(),
};
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
+ ModelChoice = entity.Model is not null
+ ? ListEditorViewModel.ModelIdToDisplay(entity.Model)
+ : "(list default)";
+ SystemPromptOverride = entity.SystemPrompt;
+ SelectedAgent = entity.AgentPath is not null
+ ? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath)
+ : null;
WindowTitle = $"Edit Task: {entity.Title}";
}
@@ -78,6 +97,11 @@ public partial class TaskEditorViewModel : ViewModelBase
CommitType = CommitType,
CreatedAt = _createdAt,
};
+ entity.Model = ModelChoice != "(list default)"
+ ? ListEditorViewModel.ModelDisplayToId(ModelChoice)
+ : null;
+ entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
+ entity.AgentPath = SelectedAgent?.Path;
_tcs.TrySetResult(entity);
RequestClose?.Invoke();
}
@@ -89,6 +113,11 @@ public partial class TaskEditorViewModel : ViewModelBase
RequestClose?.Invoke();
}
+ public void OnWindowClosed()
+ {
+ _tcs.TrySetResult(null);
+ }
+
public Task ShowAndWaitAsync()
{
_tcs = new TaskCompletionSource();
diff --git a/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs
index 5dade3c..a834084 100644
--- a/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs
@@ -14,6 +14,7 @@ public partial class TaskItemViewModel : ViewModelBase
[ObservableProperty] private string _commitType;
[ObservableProperty] private string? _description;
[ObservableProperty] private TaskStatus _status;
+ [ObservableProperty] private bool _isStarting;
public string Id { get; }
public string ListId { get; }
@@ -66,6 +67,7 @@ public partial class TaskItemViewModel : ViewModelBase
RunNowCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsRunning));
+ IsStarting = false;
OnPropertyChanged(nameof(CanToggleDone));
OnPropertyChanged(nameof(TitleDecorations));
OnPropertyChanged(nameof(TitleForeground));
@@ -73,6 +75,19 @@ public partial class TaskItemViewModel : ViewModelBase
ToggleDoneCommand.NotifyCanExecuteChanged();
}
+ public void SetStarting()
+ {
+ IsStarting = true;
+ StatusText = "starting...";
+ RunNowCommand.NotifyCanExecuteChanged();
+ }
+
+ public void ClearStarting()
+ {
+ IsStarting = false;
+ RunNowCommand.NotifyCanExecuteChanged();
+ }
+
[RelayCommand(CanExecute = nameof(CanRunNow))]
private async Task RunNowAsync()
{
@@ -81,7 +96,7 @@ public partial class TaskItemViewModel : ViewModelBase
}
private bool CanRunNow() =>
- _canRunNow() && Status == TaskStatus.Queued;
+ _canRunNow() && Status != TaskStatus.Running && !IsStarting;
[RelayCommand(CanExecute = nameof(CanToggleDone))]
private async Task ToggleDone()
diff --git a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
index baf25f7..62129db 100644
--- a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
@@ -55,6 +55,18 @@ public partial class TaskListViewModel : ViewModelBase
t.RunNowCommand.NotifyCanExecuteChanged();
});
};
+
+ worker.RunNowRequestedEvent += taskId =>
+ {
+ var item = Tasks.FirstOrDefault(t => t.Id == taskId);
+ item?.SetStarting();
+ };
+
+ worker.TaskStartedEvent += (_, taskId, _) =>
+ {
+ var item = Tasks.FirstOrDefault(t => t.Id == taskId);
+ item?.ClearStarting();
+ };
}
public async Task LoadAsync(string? listId)
@@ -134,10 +146,12 @@ public partial class TaskListViewModel : ViewModelBase
var defaultCommitType = list?.DefaultCommitType ?? "chore";
var editor = _editorFactory();
+ await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(CurrentListId, defaultCommitType);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
+ window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var saved = await editor.ShowAndWaitAsync();
@@ -179,10 +193,12 @@ public partial class TaskListViewModel : ViewModelBase
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
var editor = _editorFactory();
+ await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(entity, taskTags);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
+ window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var saved = await editor.ShowAndWaitAsync();
diff --git a/src/ClaudeDo.Ui/Views/ListEditorView.axaml b/src/ClaudeDo.Ui/Views/ListEditorView.axaml
index c9cb587..d918674 100644
--- a/src/ClaudeDo.Ui/Views/ListEditorView.axaml
+++ b/src/ClaudeDo.Ui/Views/ListEditorView.axaml
@@ -1,27 +1,61 @@
+ CanResize="False"
+ Background="{StaticResource WindowBgBrush}">
-
+
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/ClaudeDo.Ui/Views/ListEditorView.axaml.cs b/src/ClaudeDo.Ui/Views/ListEditorView.axaml.cs
index 577e70b..f017c0e 100644
--- a/src/ClaudeDo.Ui/Views/ListEditorView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/ListEditorView.axaml.cs
@@ -1,4 +1,9 @@
+using System;
+using System.IO;
using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
@@ -8,4 +13,28 @@ public partial class ListEditorView : Window
{
InitializeComponent();
}
+
+ private async void OnBrowseFolder(object? sender, RoutedEventArgs e)
+ {
+ var vm = DataContext as ListEditorViewModel;
+ var startPath = !string.IsNullOrWhiteSpace(vm?.WorkingDir) && Directory.Exists(vm.WorkingDir)
+ ? vm.WorkingDir
+ : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+
+ var startLocation = await StorageProvider.TryGetFolderFromPathAsync(new Uri(startPath));
+
+ var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Select Working Directory",
+ SuggestedStartLocation = startLocation,
+ AllowMultiple = false,
+ });
+
+ if (result.Count > 0)
+ {
+ var path = result[0].TryGetLocalPath();
+ if (path is not null && vm is not null)
+ vm.WorkingDir = path;
+ }
+ }
}
diff --git a/src/ClaudeDo.Ui/Views/TaskDetailView.axaml b/src/ClaudeDo.Ui/Views/TaskDetailView.axaml
index 2f605a9..1fb121a 100644
--- a/src/ClaudeDo.Ui/Views/TaskDetailView.axaml
+++ b/src/ClaudeDo.Ui/Views/TaskDetailView.axaml
@@ -106,20 +106,19 @@
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs b/src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs
index 70546b3..c7dbaee 100644
--- a/src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
@@ -31,4 +32,28 @@ public partial class TaskDetailView : UserControl
{
this.FindControl("TitleBox")?.Focus();
}
+
+ private TaskDetailViewModel? _previousVm;
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (_previousVm is not null)
+ _previousVm.PropertyChanged -= OnViewModelPropertyChanged;
+ _previousVm = DataContext as TaskDetailViewModel;
+ if (_previousVm is not null)
+ _previousVm.PropertyChanged += OnViewModelPropertyChanged;
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(TaskDetailViewModel.LiveText))
+ {
+ var box = this.FindControl("LiveOutputBox");
+ if (box is not null)
+ {
+ box.CaretIndex = box.Text?.Length ?? 0;
+ }
+ }
+ }
}
diff --git a/src/ClaudeDo.Ui/Views/TaskEditorView.axaml b/src/ClaudeDo.Ui/Views/TaskEditorView.axaml
index dc1f61f..f38ea79 100644
--- a/src/ClaudeDo.Ui/Views/TaskEditorView.axaml
+++ b/src/ClaudeDo.Ui/Views/TaskEditorView.axaml
@@ -1,40 +1,73 @@
+ CanResize="False"
+ Background="{StaticResource WindowBgBrush}">
-
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/ClaudeDo.Ui/Views/TaskListView.axaml b/src/ClaudeDo.Ui/Views/TaskListView.axaml
index 063bc06..8115794 100644
--- a/src/ClaudeDo.Ui/Views/TaskListView.axaml
+++ b/src/ClaudeDo.Ui/Views/TaskListView.axaml
@@ -70,6 +70,10 @@
Fill="{StaticResource StatusOrangeBrush}"
IsVisible="{Binding IsRunning}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
+
+
diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md
new file mode 100644
index 0000000..2312933
--- /dev/null
+++ b/src/ClaudeDo.Worker/CLAUDE.md
@@ -0,0 +1,67 @@
+# ClaudeDo.Worker
+
+ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments.
+
+## Architecture
+
+- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
+- **QueueService** — `BackgroundService` with two execution slots:
+ - Queue slot: FIFO sequential processing of "agent"-tagged queued tasks
+ - Override slot: immediate execution via `RunNow(taskId)`
+ - Wake signaling via `SemaphoreSlim`, backstop timer (30s default)
+- **StaleTaskRecovery** — startup-only service, flips orphaned "running" tasks to "failed"
+
+## Task Execution Pipeline
+
+`TaskRunner` orchestrates:
+1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path)
+2. Create worktree (if `WorkingDir` set) or sandbox directory
+3. Mark task "running", broadcast `TaskStarted`
+4. Build CLI args via `ClaudeArgsBuilder`; invoke `ClaudeProcess` with task prompt
+5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`)
+6. On success: auto-commit changes (worktree only), store run record, mark "done"
+7. On failure: retry once if session ID available (`--resume`), then mark "failed"
+
+## Key Components
+
+- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --dangerously-skip-permissions`. Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
+- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
+- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
+- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
+- **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
+- **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR
+- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
+
+## Execution History
+
+Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`:
+- Fields: `session_id`, input/output/cache token counts, turn count, `result` text, structured output JSON
+- Enables auto-retry on failure (resume last session) and multi-turn follow-up via `ContinueAsync`
+
+## Multi-Turn / Continue
+
+`TaskRunner.ContinueAsync` sends a follow-up prompt to an existing Claude session using `--resume ` with the stored session ID from the last run.
+
+## SignalR Hub
+
+**WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `GetAgents()`, `RefreshAgents()`
+
+**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`
+
+## Config
+
+Loaded from `~/.todo-app/worker.config.json`:
+- `db_path`, `sandbox_root`, `log_root`
+- `worktree_root_strategy` ("sibling" | "central"), `central_worktree_root`
+- `queue_backstop_interval_ms` (default 30000)
+- `signalr_port` (default 47821)
+- `claude_bin` (path to claude CLI)
+
+Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually.
+
+## Notes
+
+- The worker runs standalone — start it separately from the UI
+- Only listens on loopback (127.0.0.1)
+- ClaudeProcess uses `--dangerously-skip-permissions` — tasks run with full filesystem access
+- Worktree branches follow `claudedo/{id}` naming convention
diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs
index d46d4bb..93aa7ef 100644
--- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs
+++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs
@@ -22,4 +22,7 @@ public sealed class HubBroadcaster
public Task TaskUpdated(string taskId) =>
_hub.Clients.All.SendAsync("TaskUpdated", taskId);
+
+ public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
+ _hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
}
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index 6acbea3..ed1ce23 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -1,4 +1,5 @@
using System.Reflection;
+using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Services;
using Microsoft.AspNetCore.SignalR;
@@ -10,8 +11,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
private readonly QueueService _queue;
+ private readonly AgentFileService _agentService;
- public WorkerHub(QueueService queue) => _queue = queue;
+ public WorkerHub(QueueService queue, AgentFileService agentService)
+ {
+ _queue = queue;
+ _agentService = agentService;
+ }
public string Ping() => $"pong v{Version}";
@@ -38,7 +44,27 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
}
}
+ public async Task ContinueTask(string taskId, string followUpPrompt)
+ {
+ try
+ {
+ return await _queue.ContinueTask(taskId, followUpPrompt);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new HubException(ex.Message);
+ }
+ catch (KeyNotFoundException)
+ {
+ throw new HubException("task not found");
+ }
+ }
+
public bool CancelTask(string taskId) => _queue.CancelTask(taskId);
public void WakeQueue() => _queue.WakeQueue();
+
+ public async Task> GetAgents() => await _agentService.ScanAsync();
+
+ public async Task RefreshAgents() => await _agentService.ScanAsync();
}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index 75b8171..84af2ec 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -20,6 +20,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddHostedService();
builder.Services.AddSignalR();
@@ -28,8 +29,14 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// Agent file management.
+var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
+Directory.CreateDirectory(agentsDir);
+builder.Services.AddSingleton(new AgentFileService(agentsDir));
+
// QueueService: singleton + hosted service (same instance).
builder.Services.AddSingleton();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
diff --git a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs
new file mode 100644
index 0000000..1d612cc
--- /dev/null
+++ b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs
@@ -0,0 +1,65 @@
+using System.Text.Json;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed record ClaudeRunConfig(
+ string? Model,
+ string? SystemPrompt,
+ string? AgentPath,
+ string? ResumeSessionId
+);
+
+public sealed class ClaudeArgsBuilder
+{
+ private static readonly string ResultSchema = JsonSerializer.Serialize(new
+ {
+ type = "object",
+ properties = new
+ {
+ summary = new { type = "string" },
+ files_changed = new { type = "array", items = new { type = "string" } },
+ commit_type = new { type = "string" },
+ },
+ required = new[] { "summary" },
+ });
+
+ public string Build(ClaudeRunConfig config)
+ {
+ var args = new List
+ {
+ "-p",
+ "--output-format stream-json",
+ "--verbose",
+ "--dangerously-skip-permissions",
+ };
+
+ if (config.Model is not null)
+ args.Add($"--model {config.Model}");
+
+ if (config.SystemPrompt is not null)
+ args.Add($"--append-system-prompt {Escape(config.SystemPrompt)}");
+
+ if (config.AgentPath is not null)
+ {
+ var agentJson = JsonSerializer.Serialize(new[] { new { file = config.AgentPath } });
+ args.Add($"--agents {Escape(agentJson)}");
+ }
+
+ args.Add($"--json-schema {Escape(ResultSchema)}");
+
+ if (config.ResumeSessionId is not null)
+ args.Add($"--resume {config.ResumeSessionId}");
+
+ return string.Join(" ", args);
+ }
+
+ private static string Escape(string value)
+ {
+ if (value.Contains(' ') || value.Contains('"') || value.Contains('\''))
+ {
+ var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+ return $"\"{escaped}\"";
+ }
+ return value;
+ }
+}
diff --git a/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs b/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs
index 1a4d0a5..1cbe132 100644
--- a/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs
+++ b/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs
@@ -16,17 +16,16 @@ public sealed class ClaudeProcess : IClaudeProcess
}
public async Task RunAsync(
+ string arguments,
string prompt,
string workingDirectory,
- string logPath,
- string taskId,
Func onStdoutLine,
CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = _cfg.ClaudeBin,
- Arguments = "-p --output-format stream-json --verbose --dangerously-skip-permissions",
+ Arguments = arguments,
WorkingDirectory = workingDirectory,
RedirectStandardInput = true,
RedirectStandardOutput = true,
@@ -40,30 +39,25 @@ public sealed class ClaudeProcess : IClaudeProcess
using var process = new Process { StartInfo = psi };
process.Start();
- // Write prompt to stdin, then close.
await process.StandardInput.WriteAsync(prompt);
process.StandardInput.Close();
- string? resultMarkdown = null;
+ var analyzer = new StreamAnalyzer();
var lastStderr = new StringBuilder();
- // Register cancellation to kill the process tree.
await using var ctr = ct.Register(() =>
{
try { process.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
- // Read stdout and stderr concurrently.
var stdoutTask = Task.Run(async () =>
{
while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
{
if (string.IsNullOrEmpty(line)) continue;
await onStdoutLine(line);
-
- if (MessageParser.TryExtractResult(line, out var res))
- resultMarkdown = res;
+ analyzer.ProcessLine(line);
}
}, ct);
@@ -81,16 +75,34 @@ public sealed class ClaudeProcess : IClaudeProcess
await process.WaitForExitAsync(ct);
var exitCode = process.ExitCode;
+ var streamResult = analyzer.GetResult();
- if (exitCode == 0 && resultMarkdown is not null)
+ if (exitCode == 0 && streamResult.ResultMarkdown is not null)
{
- return new RunResult { ExitCode = exitCode, ResultMarkdown = resultMarkdown };
+ return new RunResult
+ {
+ ExitCode = exitCode,
+ ResultMarkdown = streamResult.ResultMarkdown,
+ StructuredOutputJson = streamResult.StructuredOutputJson,
+ SessionId = streamResult.SessionId,
+ TurnCount = streamResult.TurnCount,
+ TokensIn = streamResult.TokensIn,
+ TokensOut = streamResult.TokensOut,
+ };
}
var error = lastStderr.Length > 0
? lastStderr.ToString().Trim()
: $"Claude exited with code {exitCode} and no result.";
- return new RunResult { ExitCode = exitCode, ErrorMarkdown = error };
+ return new RunResult
+ {
+ ExitCode = exitCode,
+ ErrorMarkdown = error,
+ SessionId = streamResult.SessionId,
+ TurnCount = streamResult.TurnCount,
+ TokensIn = streamResult.TokensIn,
+ TokensOut = streamResult.TokensOut,
+ };
}
}
diff --git a/src/ClaudeDo.Worker/Runner/IClaudeProcess.cs b/src/ClaudeDo.Worker/Runner/IClaudeProcess.cs
index e3fb057..ebd18b8 100644
--- a/src/ClaudeDo.Worker/Runner/IClaudeProcess.cs
+++ b/src/ClaudeDo.Worker/Runner/IClaudeProcess.cs
@@ -3,10 +3,9 @@ namespace ClaudeDo.Worker.Runner;
public interface IClaudeProcess
{
Task RunAsync(
+ string arguments,
string prompt,
string workingDirectory,
- string logPath,
- string taskId,
Func onStdoutLine,
CancellationToken ct);
}
diff --git a/src/ClaudeDo.Worker/Runner/MessageParser.cs b/src/ClaudeDo.Worker/Runner/MessageParser.cs
deleted file mode 100644
index 9343569..0000000
--- a/src/ClaudeDo.Worker/Runner/MessageParser.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.Text.Json;
-
-namespace ClaudeDo.Worker.Runner;
-
-public static class MessageParser
-{
- public static bool TryExtractResult(string ndjsonLine, out string? result)
- {
- result = null;
- if (string.IsNullOrWhiteSpace(ndjsonLine))
- return false;
-
- try
- {
- using var doc = JsonDocument.Parse(ndjsonLine);
- var root = doc.RootElement;
-
- if (root.TryGetProperty("type", out var typeProp) &&
- typeProp.GetString() == "result" &&
- root.TryGetProperty("result", out var resultProp))
- {
- result = resultProp.GetString();
- return true;
- }
- }
- catch (JsonException)
- {
- // Malformed JSON — not a result line.
- }
-
- return false;
- }
-}
diff --git a/src/ClaudeDo.Worker/Runner/RunResult.cs b/src/ClaudeDo.Worker/Runner/RunResult.cs
index bed0cff..07d3010 100644
--- a/src/ClaudeDo.Worker/Runner/RunResult.cs
+++ b/src/ClaudeDo.Worker/Runner/RunResult.cs
@@ -5,6 +5,11 @@ public sealed class RunResult
public required int ExitCode { get; init; }
public string? ResultMarkdown { get; init; }
public string? ErrorMarkdown { get; init; }
+ public string? StructuredOutputJson { get; init; }
+ public string? SessionId { get; init; }
+ public int TurnCount { get; init; }
+ public int TokensIn { get; init; }
+ public int TokensOut { get; init; }
public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null;
}
diff --git a/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs b/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs
new file mode 100644
index 0000000..f50d386
--- /dev/null
+++ b/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs
@@ -0,0 +1,79 @@
+using System.Text.Json;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class StreamAnalyzer
+{
+ private string? _resultMarkdown;
+ private string? _structuredOutputJson;
+ private string? _sessionId;
+ private int _turnCount;
+ private int _tokensIn;
+ private int _tokensOut;
+ private int _apiRetryCount;
+
+ public void ProcessLine(string ndjsonLine)
+ {
+ if (string.IsNullOrWhiteSpace(ndjsonLine)) return;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(ndjsonLine);
+ var root = doc.RootElement;
+
+ if (!root.TryGetProperty("type", out var typeProp)) return;
+ var type = typeProp.GetString();
+
+ switch (type)
+ {
+ case "result":
+ if (root.TryGetProperty("result", out var resultProp))
+ _resultMarkdown = resultProp.GetString();
+ if (root.TryGetProperty("structured_output", out var structuredProp))
+ _structuredOutputJson = structuredProp.ToString();
+ if (root.TryGetProperty("session_id", out var sessionProp))
+ _sessionId = sessionProp.GetString();
+ break;
+
+ case "assistant":
+ _turnCount++;
+ break;
+
+ case "system":
+ if (root.TryGetProperty("subtype", out var subtypeProp) &&
+ subtypeProp.GetString() == "api_retry")
+ _apiRetryCount++;
+ break;
+
+ case "stream_event":
+ TryAccumulateUsage(root);
+ break;
+ }
+ }
+ catch (JsonException) { /* Malformed JSON — skip */ }
+ }
+
+ public StreamResult GetResult() => new()
+ {
+ ResultMarkdown = _resultMarkdown,
+ StructuredOutputJson = _structuredOutputJson,
+ SessionId = _sessionId,
+ TurnCount = _turnCount,
+ TokensIn = _tokensIn,
+ TokensOut = _tokensOut,
+ ApiRetryCount = _apiRetryCount,
+ };
+
+ private void TryAccumulateUsage(JsonElement root)
+ {
+ if (!root.TryGetProperty("event", out var eventProp)) return;
+ if (eventProp.TryGetProperty("message", out var msgProp) &&
+ msgProp.TryGetProperty("usage", out var usageProp))
+ {
+ if (usageProp.TryGetProperty("input_tokens", out var inp))
+ _tokensIn += inp.GetInt32();
+ if (usageProp.TryGetProperty("output_tokens", out var outp))
+ _tokensOut += outp.GetInt32();
+ }
+ }
+}
diff --git a/src/ClaudeDo.Worker/Runner/StreamResult.cs b/src/ClaudeDo.Worker/Runner/StreamResult.cs
new file mode 100644
index 0000000..212e5ad
--- /dev/null
+++ b/src/ClaudeDo.Worker/Runner/StreamResult.cs
@@ -0,0 +1,12 @@
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class StreamResult
+{
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? SessionId { get; set; }
+ public int TurnCount { get; set; }
+ public int TokensIn { get; set; }
+ public int TokensOut { get; set; }
+ public int ApiRetryCount { get; set; }
+}
diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs
index 1b9f517..d40cad1 100644
--- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs
+++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs
@@ -1,3 +1,4 @@
+using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
@@ -8,31 +9,40 @@ public sealed class TaskRunner
{
private readonly IClaudeProcess _claude;
private readonly TaskRepository _taskRepo;
+ private readonly TaskRunRepository _runRepo;
private readonly ListRepository _listRepo;
+ private readonly WorktreeRepository _wtRepo;
private readonly HubBroadcaster _broadcaster;
private readonly WorktreeManager _wtManager;
+ private readonly ClaudeArgsBuilder _argsBuilder;
private readonly WorkerConfig _cfg;
private readonly ILogger _logger;
public TaskRunner(
IClaudeProcess claude,
TaskRepository taskRepo,
+ TaskRunRepository runRepo,
ListRepository listRepo,
+ WorktreeRepository wtRepo,
HubBroadcaster broadcaster,
WorktreeManager wtManager,
+ ClaudeArgsBuilder argsBuilder,
WorkerConfig cfg,
ILogger logger)
{
_claude = claude;
_taskRepo = taskRepo;
+ _runRepo = runRepo;
_listRepo = listRepo;
+ _wtRepo = wtRepo;
_broadcaster = broadcaster;
_wtManager = wtManager;
+ _argsBuilder = argsBuilder;
_cfg = cfg;
_logger = logger;
}
- public async Task RunAsync(Data.Models.TaskEntity task, string slot, CancellationToken ct)
+ public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
{
try
{
@@ -63,14 +73,19 @@ public sealed class TaskRunner
}
else
{
- // Non-worktree sandbox path.
runDir = Path.Combine(_cfg.SandboxRoot, task.Id);
Directory.CreateDirectory(runDir);
}
- var logPath = Path.Combine(_cfg.LogRoot, $"{task.Id}.ndjson");
+ // Resolve config: task overrides > list config > null.
+ var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
+ var resolvedConfig = new ClaudeRunConfig(
+ Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
+ SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
+ AgentPath: task.AgentPath ?? listConfig?.AgentPath,
+ ResumeSessionId: null
+ );
- await _taskRepo.SetLogPathAsync(task.Id, logPath, ct);
var now = DateTime.UtcNow;
await _taskRepo.MarkRunningAsync(task.Id, now, ct);
await _broadcaster.TaskStarted(slot, task.Id, now);
@@ -80,42 +95,38 @@ public sealed class TaskRunner
? task.Title
: $"{task.Title}\n\n{task.Description.Trim()}";
- await using var logWriter = new LogWriter(logPath);
-
- var result = await _claude.RunAsync(
- prompt,
- runDir,
- logPath,
- task.Id,
- async line =>
- {
- await logWriter.WriteLineAsync(line, ct);
- await _broadcaster.TaskMessage(task.Id, line);
- },
- ct);
-
- var finishedAt = DateTime.UtcNow;
+ // Run 1.
+ var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
if (result.IsSuccess)
{
- // Auto-commit if worktree mode and run succeeded.
- if (wtCtx is not null)
- {
- var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
- if (committed)
- await _broadcaster.WorktreeUpdated(task.Id);
- }
-
- await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct);
- await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
- _logger.LogInformation("Task {TaskId} completed successfully", task.Id);
+ await HandleSuccess(task, list, slot, wtCtx, result, ct);
}
else
{
- // Failed run: do NOT commit. Worktree row stays active for inspection.
- await _taskRepo.MarkFailedAsync(task.Id, finishedAt, result.ErrorMarkdown, ct);
- await _broadcaster.TaskFinished(slot, task.Id, "failed", finishedAt);
- _logger.LogWarning("Task {TaskId} failed: {Error}", task.Id, result.ErrorMarkdown);
+ // Auto-retry: one attempt if we have a session ID.
+ if (result.SessionId is not null)
+ {
+ _logger.LogInformation("Auto-retrying task {TaskId} with session {SessionId}", task.Id, result.SessionId);
+ var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
+ var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
+
+ await _broadcaster.RunCreated(task.Id, 2, true);
+ var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
+
+ if (retryResult.IsSuccess)
+ {
+ await HandleSuccess(task, list, slot, wtCtx, retryResult, ct);
+ }
+ else
+ {
+ await HandleFailure(task.Id, slot, retryResult);
+ }
+ }
+ else
+ {
+ await HandleFailure(task.Id, slot, result);
+ }
}
await _broadcaster.TaskUpdated(task.Id);
@@ -132,6 +143,138 @@ public sealed class TaskRunner
}
}
+ public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
+ {
+ var task = await _taskRepo.GetByIdAsync(taskId, ct)
+ ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
+
+ var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct)
+ ?? throw new InvalidOperationException("No previous run to continue.");
+
+ if (lastRun.SessionId is null)
+ throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
+
+ var list = await _listRepo.GetByIdAsync(task.ListId, ct)
+ ?? throw new InvalidOperationException("List not found.");
+
+ var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
+ var resolvedConfig = new ClaudeRunConfig(
+ Model: task.Model ?? listConfig?.Model,
+ SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
+ AgentPath: task.AgentPath ?? listConfig?.AgentPath,
+ ResumeSessionId: lastRun.SessionId
+ );
+
+ // Determine run directory from existing worktree or sandbox.
+ string runDir;
+ WorktreeContext? wtCtx = null;
+ var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct);
+ if (worktree is not null)
+ {
+ runDir = worktree.Path;
+ wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
+ }
+ else
+ {
+ runDir = Path.Combine(_cfg.SandboxRoot, taskId);
+ }
+
+ var now = DateTime.UtcNow;
+ await _taskRepo.MarkRunningAsync(taskId, now, ct);
+ await _broadcaster.TaskStarted(slot, taskId, now);
+
+ var nextRunNumber = lastRun.RunNumber + 1;
+ await _broadcaster.RunCreated(taskId, nextRunNumber, false);
+ var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
+
+ if (result.IsSuccess)
+ {
+ await HandleSuccess(task, list, slot, wtCtx, result, ct);
+ }
+ else
+ {
+ await HandleFailure(taskId, slot, result);
+ }
+
+ await _broadcaster.TaskUpdated(taskId);
+ }
+
+ private async Task RunOnceAsync(
+ string taskId, string slot, string runDir, ClaudeRunConfig config,
+ int runNumber, bool isRetry, string prompt, CancellationToken ct)
+ {
+ var runId = Guid.NewGuid().ToString();
+ var logPath = Path.Combine(_cfg.LogRoot, $"{taskId}_run{runNumber}.ndjson");
+
+ var run = new TaskRunEntity
+ {
+ Id = runId,
+ TaskId = taskId,
+ RunNumber = runNumber,
+ IsRetry = isRetry,
+ Prompt = prompt,
+ LogPath = logPath,
+ StartedAt = DateTime.UtcNow,
+ };
+ await _runRepo.AddAsync(run, ct);
+
+ var arguments = _argsBuilder.Build(config);
+
+ await using var logWriter = new LogWriter(logPath);
+
+ var result = await _claude.RunAsync(
+ arguments,
+ prompt,
+ runDir,
+ async line =>
+ {
+ await logWriter.WriteLineAsync(line, ct);
+ await _broadcaster.TaskMessage(taskId, line);
+ },
+ ct);
+
+ // Update the run record with results.
+ run.SessionId = result.SessionId;
+ run.ResultMarkdown = result.ResultMarkdown;
+ run.StructuredOutputJson = result.StructuredOutputJson;
+ run.ErrorMarkdown = result.ErrorMarkdown;
+ run.ExitCode = result.ExitCode;
+ run.TurnCount = result.TurnCount;
+ run.TokensIn = result.TokensIn;
+ run.TokensOut = result.TokensOut;
+ run.FinishedAt = DateTime.UtcNow;
+ await _runRepo.UpdateAsync(run, ct);
+
+ // Update denormalized fields on the task.
+ await _taskRepo.SetLogPathAsync(taskId, logPath, ct);
+
+ return result;
+ }
+
+ private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
+ {
+ if (wtCtx is not null)
+ {
+ var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
+ if (committed)
+ await _broadcaster.WorktreeUpdated(task.Id);
+ }
+
+ var finishedAt = DateTime.UtcNow;
+ await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct);
+ await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
+ _logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
+ task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
+ }
+
+ private async Task HandleFailure(string taskId, string slot, RunResult result)
+ {
+ var finishedAt = DateTime.UtcNow;
+ await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown);
+ await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
+ _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
+ }
+
private async Task MarkFailed(string taskId, string slot, string error)
{
try
diff --git a/src/ClaudeDo.Worker/Services/AgentFileService.cs b/src/ClaudeDo.Worker/Services/AgentFileService.cs
new file mode 100644
index 0000000..adc598a
--- /dev/null
+++ b/src/ClaudeDo.Worker/Services/AgentFileService.cs
@@ -0,0 +1,76 @@
+using ClaudeDo.Data.Models;
+
+namespace ClaudeDo.Worker.Services;
+
+public sealed class AgentFileService
+{
+ private readonly string _agentsDir;
+
+ public AgentFileService(string agentsDir)
+ {
+ _agentsDir = agentsDir;
+ }
+
+ public Task> ScanAsync(CancellationToken ct = default)
+ {
+ var agents = new List();
+ if (!Directory.Exists(_agentsDir))
+ return Task.FromResult(agents);
+
+ foreach (var file in Directory.EnumerateFiles(_agentsDir, "*.md"))
+ {
+ ct.ThrowIfCancellationRequested();
+ var (name, description) = ParseFrontmatter(file);
+ agents.Add(new AgentInfo(name, description, file));
+ }
+
+ agents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
+ return Task.FromResult(agents);
+ }
+
+ public async Task ReadAsync(string path, CancellationToken ct = default)
+ {
+ return await File.ReadAllTextAsync(path, ct);
+ }
+
+ public async Task WriteAsync(string path, string content, CancellationToken ct = default)
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (dir is not null) Directory.CreateDirectory(dir);
+ await File.WriteAllTextAsync(path, content, ct);
+ }
+
+ public Task DeleteAsync(string path, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (File.Exists(path)) File.Delete(path);
+ return Task.CompletedTask;
+ }
+
+ private static (string name, string description) ParseFrontmatter(string filePath)
+ {
+ var fileName = Path.GetFileNameWithoutExtension(filePath);
+ string name = fileName;
+ string description = "";
+
+ try
+ {
+ using var reader = new StreamReader(filePath);
+ var firstLine = reader.ReadLine();
+ if (firstLine?.Trim() != "---")
+ return (name, description);
+
+ while (reader.ReadLine() is { } line)
+ {
+ if (line.Trim() == "---") break;
+ if (line.StartsWith("name:"))
+ name = line["name:".Length..].Trim();
+ else if (line.StartsWith("description:"))
+ description = line["description:".Length..].Trim();
+ }
+ }
+ catch { /* Can't read file -- use filename fallback */ }
+
+ return (name, description);
+ }
+}
diff --git a/src/ClaudeDo.Worker/Services/QueueService.cs b/src/ClaudeDo.Worker/Services/QueueService.cs
index 19f8bed..e3912c5 100644
--- a/src/ClaudeDo.Worker/Services/QueueService.cs
+++ b/src/ClaudeDo.Worker/Services/QueueService.cs
@@ -75,6 +75,31 @@ public sealed class QueueService : BackgroundService
}
}
+ public async Task ContinueTask(string taskId, string followUpPrompt)
+ {
+ var task = await _taskRepo.GetByIdAsync(taskId)
+ ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
+
+ if (task.Status == Data.Models.TaskStatus.Running)
+ throw new InvalidOperationException("Task is currently running.");
+
+ lock (_lock)
+ {
+ if (_overrideSlot is not null)
+ throw new InvalidOperationException("override slot busy");
+
+ var cts = new CancellationTokenSource();
+ _overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
+
+ _ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
+ {
+ lock (_lock) { _overrideSlot = null; }
+ }, TaskScheduler.Default);
+ }
+
+ return taskId;
+ }
+
public bool CancelTask(string taskId)
{
lock (_lock)
@@ -159,4 +184,17 @@ public sealed class QueueService : BackgroundService
_logger.LogError(ex, "Slot runner error for task {TaskId}", task.Id);
}
}
+
+ private async Task RunContinueInSlotAsync(string taskId, string followUpPrompt, CancellationToken ct)
+ {
+ try
+ {
+ _logger.LogInformation("Continuing task {TaskId} in override slot", taskId);
+ await _runner.ContinueAsync(taskId, followUpPrompt, "override", ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Continue runner error for task {TaskId}", taskId);
+ }
+ }
}
diff --git a/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj b/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
new file mode 100644
index 0000000..ac032c8
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs b/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
new file mode 100644
index 0000000..fdcb601
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
@@ -0,0 +1,138 @@
+using ClaudeDo.Ui.Helpers;
+
+namespace ClaudeDo.Ui.Tests.Helpers;
+
+public class StreamLineFormatterTests
+{
+ private readonly StreamLineFormatter _formatter = new();
+
+ // --- Text deltas ---
+
+ [Fact]
+ public void FormatLine_TextDelta_ReturnsTextContent()
+ {
+ var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello world"}}}""";
+ Assert.Equal("Hello world", _formatter.FormatLine(line));
+ }
+
+ [Fact]
+ public void FormatLine_ConsecutiveTextDeltas_ReturnEachDelta()
+ {
+ var line1 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello "}}}""";
+ var line2 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}""";
+ Assert.Equal("Hello ", _formatter.FormatLine(line1));
+ Assert.Equal("world", _formatter.FormatLine(line2));
+ }
+
+ [Fact]
+ public void FormatLine_ContentBlockStop_ReturnsNewline()
+ {
+ var line = """{"type":"stream_event","event":{"type":"content_block_stop","index":0}}""";
+ Assert.Equal("\n", _formatter.FormatLine(line));
+ }
+
+ // --- Tool use, result, system, fallback ---
+
+ [Fact]
+ public void FormatLine_ToolUseStart_ReturnsToolNameLine()
+ {
+ var line = """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"x","name":"bash"}}}""";
+ Assert.Equal("\n[Tool: bash]\n", _formatter.FormatLine(line));
+ }
+
+ [Fact]
+ public void FormatLine_InputJsonDelta_ReturnsNull()
+ {
+ var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":"}}}""";
+ Assert.Null(_formatter.FormatLine(line));
+ }
+
+ [Fact]
+ public void FormatLine_Result_ReturnsFormattedResult()
+ {
+ var line = """{"type":"result","result":"Done."}""";
+ Assert.Equal("\n--- Result ---\nDone.\n", _formatter.FormatLine(line));
+ }
+
+ [Fact]
+ public void FormatLine_ApiRetry_ReturnsRetryNotice()
+ {
+ var line = """{"type":"system","subtype":"api_retry"}""";
+ Assert.Equal("\n[Retrying API call...]\n", _formatter.FormatLine(line));
+ }
+
+ [Fact]
+ public void FormatLine_SystemNonRetry_ReturnsNull()
+ {
+ var line = """{"type":"system","subtype":"init"}""";
+ Assert.Null(_formatter.FormatLine(line));
+ }
+
+ [Fact]
+ public void FormatLine_AssistantType_ReturnsNull()
+ {
+ var line = """{"type":"assistant","message":{}}""";
+ Assert.Null(_formatter.FormatLine(line));
+ }
+
+ [Fact]
+ public void FormatLine_MalformedJson_ReturnsRawLine()
+ {
+ var line = "not json at all";
+ Assert.Equal("not json at all", _formatter.FormatLine(line));
+ }
+
+ [Fact]
+ public void FormatLine_MessageStartAndDelta_ReturnsNull()
+ {
+ var start = """{"type":"stream_event","event":{"type":"message_start","message":{}}}""";
+ var delta = """{"type":"stream_event","event":{"type":"message_delta","delta":{}}}""";
+ Assert.Null(_formatter.FormatLine(start));
+ Assert.Null(_formatter.FormatLine(delta));
+ }
+
+ // --- FormatFile and Trim ---
+
+ [Fact]
+ public void FormatFile_ParsesAllLinesAndReturnsFormattedText()
+ {
+ var lines = new[]
+ {
+ """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}""",
+ """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"x","name":"bash"}}}""",
+ """{"type":"result","result":"Done."}""",
+ };
+ var file = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllLines(file, lines);
+ var result = _formatter.FormatFile(file);
+ Assert.Contains("Hello", result);
+ Assert.Contains("[Tool: bash]", result);
+ Assert.Contains("Done.", result);
+ }
+ finally
+ {
+ File.Delete(file);
+ }
+ }
+
+ [Fact]
+ public void FormatFile_TrimsLargeContent()
+ {
+ var chunk = new string('x', 1000);
+ var line = "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"" + chunk + "\"}}}";
+ var lines = Enumerable.Repeat(line, 65).ToArray();
+ var file = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllLines(file, lines);
+ var result = _formatter.FormatFile(file);
+ Assert.True(result.Length <= 50_200, $"Expected <= 50200 but got {result.Length}");
+ }
+ finally
+ {
+ File.Delete(file);
+ }
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/CLAUDE.md b/tests/ClaudeDo.Worker.Tests/CLAUDE.md
new file mode 100644
index 0000000..9d437f1
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/CLAUDE.md
@@ -0,0 +1,41 @@
+# ClaudeDo.Worker.Tests
+
+xUnit integration tests for the Worker and Data layers.
+
+## Framework
+
+- xUnit 2.5.3 with `xunit.runner.visualstudio`
+- No mocking library — custom sealed fakes (FakeClaudeProcess, FakeHubContext, FakeHubClients, FakeClientProxy)
+- Real SQLite databases per test via `DbFixture`
+- Real git repos for worktree tests via `GitRepoFixture`
+- coverlet for coverage collection
+
+## Test Infrastructure
+
+- **DbFixture** — creates unique temp SQLite DB, applies schema, cleans up DB + WAL/SHM files on dispose
+- **GitRepoFixture** — creates temp git repo with initial commit, configures user/email, handles Windows read-only .git cleanup. Tests skip if git is unavailable.
+
+## Test Areas
+
+| Area | File | What it covers |
+|------|------|----------------|
+| Repositories | `ListRepositoryTests` | CRUD, tag junctions |
+| | `TaskRepositoryTests` | CRUD, status transitions, agent tag filtering, effective tags, stale flip |
+| Runner | `WorktreeManagerTests` | Worktree creation, commit detection, error on non-git dir |
+| | `CommitMessageBuilderTests` | Slug generation, title/description truncation |
+| | `MessageParserTests` | NDJSON parsing, malformed input |
+| Services | `QueueServiceTests` | FIFO ordering, override slot contention, cancellation, active tracking |
+| | `StaleTaskRecoveryTests` | Flips orphaned running tasks to failed |
+
+## Conventions
+
+- Test classes implement `IDisposable` and create fixtures in constructor
+- Helper factory methods for entities: `MakeTask()`, `CreateListAsync()`, `SeedListWithAgentTag()`
+- Concurrency tests use `TaskCompletionSource` as gates for deterministic ordering
+- Git-dependent tests are conditionally skipped via `Skip = ...` when git is not available
+
+## Running
+
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests
+```
diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs
new file mode 100644
index 0000000..a9068f3
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs
@@ -0,0 +1,61 @@
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Tests.Infrastructure;
+
+namespace ClaudeDo.Worker.Tests.Repositories;
+
+public sealed class ListRepositoryConfigTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly ListRepository _repo;
+ private readonly string _listId;
+
+ public ListRepositoryConfigTests()
+ {
+ _repo = new ListRepository(_db.Factory);
+ _listId = Guid.NewGuid().ToString();
+ _repo.AddAsync(new ListEntity
+ {
+ Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow
+ }).GetAwaiter().GetResult();
+ }
+
+ [Fact]
+ public async Task GetConfig_Returns_Null_When_No_Config()
+ {
+ var config = await _repo.GetConfigAsync(_listId);
+ Assert.Null(config);
+ }
+
+ [Fact]
+ public async Task SetConfig_And_GetConfig_Roundtrips()
+ {
+ var config = new ListConfigEntity
+ {
+ ListId = _listId,
+ Model = "sonnet-4-6",
+ SystemPrompt = "You are helpful.",
+ AgentPath = "/home/user/.todo-app/agents/dev.md",
+ };
+ await _repo.SetConfigAsync(config);
+
+ var fetched = await _repo.GetConfigAsync(_listId);
+ Assert.NotNull(fetched);
+ Assert.Equal("sonnet-4-6", fetched.Model);
+ Assert.Equal("You are helpful.", fetched.SystemPrompt);
+ Assert.Equal("/home/user/.todo-app/agents/dev.md", fetched.AgentPath);
+ }
+
+ [Fact]
+ public async Task SetConfig_Upserts_On_Duplicate()
+ {
+ await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, Model = "opus-4-6" });
+ await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, Model = "haiku-4-5" });
+
+ var fetched = await _repo.GetConfigAsync(_listId);
+ Assert.NotNull(fetched);
+ Assert.Equal("haiku-4-5", fetched.Model);
+ }
+
+ public void Dispose() => _db.Dispose();
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs
new file mode 100644
index 0000000..0d06bb1
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs
@@ -0,0 +1,170 @@
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Tests.Infrastructure;
+
+namespace ClaudeDo.Worker.Tests.Repositories;
+
+public sealed class TaskRunRepositoryTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly TaskRunRepository _runs;
+ private readonly string _taskId;
+
+ public TaskRunRepositoryTests()
+ {
+ _runs = new TaskRunRepository(_db.Factory);
+
+ // Seed a list and task for all tests
+ var lists = new ListRepository(_db.Factory);
+ var tasks = new TaskRepository(_db.Factory);
+ var listId = Guid.NewGuid().ToString();
+ lists.AddAsync(new ListEntity
+ {
+ Id = listId,
+ Name = "Test List",
+ CreatedAt = DateTime.UtcNow,
+ }).GetAwaiter().GetResult();
+
+ _taskId = Guid.NewGuid().ToString();
+ tasks.AddAsync(new TaskEntity
+ {
+ Id = _taskId,
+ ListId = listId,
+ Title = "Test Task",
+ Status = Data.Models.TaskStatus.Queued,
+ CreatedAt = DateTime.UtcNow,
+ CommitType = "feat",
+ }).GetAwaiter().GetResult();
+ }
+
+ public void Dispose() => _db.Dispose();
+
+ private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
+ {
+ Id = Guid.NewGuid().ToString(),
+ TaskId = _taskId,
+ RunNumber = runNumber,
+ IsRetry = isRetry,
+ Prompt = $"Do something (run {runNumber})",
+ StartedAt = DateTime.UtcNow,
+ };
+
+ [Fact]
+ public async Task Add_And_GetById_Roundtrips()
+ {
+ var entity = new TaskRunEntity
+ {
+ Id = Guid.NewGuid().ToString(),
+ TaskId = _taskId,
+ RunNumber = 1,
+ SessionId = "sess-abc",
+ IsRetry = false,
+ Prompt = "Fix the bug",
+ ResultMarkdown = "All done",
+ StructuredOutputJson = """{"ok":true}""",
+ ErrorMarkdown = null,
+ ExitCode = 0,
+ TurnCount = 5,
+ TokensIn = 1000,
+ TokensOut = 2000,
+ LogPath = "/tmp/run1.ndjson",
+ StartedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ FinishedAt = new DateTime(2026, 1, 1, 0, 5, 0, DateTimeKind.Utc),
+ };
+
+ await _runs.AddAsync(entity);
+ var loaded = await _runs.GetByIdAsync(entity.Id);
+
+ Assert.NotNull(loaded);
+ Assert.Equal(entity.Id, loaded.Id);
+ Assert.Equal(entity.TaskId, loaded.TaskId);
+ Assert.Equal(entity.RunNumber, loaded.RunNumber);
+ Assert.Equal(entity.SessionId, loaded.SessionId);
+ Assert.Equal(entity.IsRetry, loaded.IsRetry);
+ Assert.Equal(entity.Prompt, loaded.Prompt);
+ Assert.Equal(entity.ResultMarkdown, loaded.ResultMarkdown);
+ Assert.Equal(entity.StructuredOutputJson, loaded.StructuredOutputJson);
+ Assert.Null(loaded.ErrorMarkdown);
+ Assert.Equal(entity.ExitCode, loaded.ExitCode);
+ Assert.Equal(entity.TurnCount, loaded.TurnCount);
+ Assert.Equal(entity.TokensIn, loaded.TokensIn);
+ Assert.Equal(entity.TokensOut, loaded.TokensOut);
+ Assert.Equal(entity.LogPath, loaded.LogPath);
+ Assert.Equal(entity.StartedAt, loaded.StartedAt);
+ Assert.Equal(entity.FinishedAt, loaded.FinishedAt);
+ }
+
+ [Fact]
+ public async Task GetByTaskId_Returns_Ordered_By_RunNumber()
+ {
+ var run3 = MakeRun(3);
+ var run1 = MakeRun(1);
+ var run2 = MakeRun(2);
+
+ await _runs.AddAsync(run3);
+ await _runs.AddAsync(run1);
+ await _runs.AddAsync(run2);
+
+ var runs = await _runs.GetByTaskIdAsync(_taskId);
+
+ Assert.Equal(3, runs.Count);
+ Assert.Equal(1, runs[0].RunNumber);
+ Assert.Equal(2, runs[1].RunNumber);
+ Assert.Equal(3, runs[2].RunNumber);
+ }
+
+ [Fact]
+ public async Task GetLatestByTaskId_Returns_Highest_RunNumber()
+ {
+ var run1 = MakeRun(1);
+ var run2 = MakeRun(2);
+
+ await _runs.AddAsync(run1);
+ await _runs.AddAsync(run2);
+
+ var latest = await _runs.GetLatestByTaskIdAsync(_taskId);
+
+ Assert.NotNull(latest);
+ Assert.Equal(run2.Id, latest.Id);
+ Assert.Equal(2, latest.RunNumber);
+ }
+
+ [Fact]
+ public async Task Update_Persists_Completion_Fields()
+ {
+ var run = MakeRun(1);
+ await _runs.AddAsync(run);
+
+ run.SessionId = "sess-xyz";
+ run.ResultMarkdown = "Task completed";
+ run.StructuredOutputJson = """{"status":"done"}""";
+ run.ErrorMarkdown = null;
+ run.ExitCode = 0;
+ run.TurnCount = 12;
+ run.TokensIn = 5000;
+ run.TokensOut = 8000;
+ run.FinishedAt = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
+
+ await _runs.UpdateAsync(run);
+ var loaded = await _runs.GetByIdAsync(run.Id);
+
+ Assert.NotNull(loaded);
+ Assert.Equal("sess-xyz", loaded.SessionId);
+ Assert.Equal("Task completed", loaded.ResultMarkdown);
+ Assert.Equal("""{"status":"done"}""", loaded.StructuredOutputJson);
+ Assert.Null(loaded.ErrorMarkdown);
+ Assert.Equal(0, loaded.ExitCode);
+ Assert.Equal(12, loaded.TurnCount);
+ Assert.Equal(5000, loaded.TokensIn);
+ Assert.Equal(8000, loaded.TokensOut);
+ Assert.Equal(new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc), loaded.FinishedAt);
+ }
+
+ [Fact]
+ public async Task GetLatestByTaskId_Returns_Null_When_No_Runs()
+ {
+ var latest = await _runs.GetLatestByTaskIdAsync(Guid.NewGuid().ToString());
+
+ Assert.Null(latest);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs
new file mode 100644
index 0000000..8887f87
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs
@@ -0,0 +1,71 @@
+using ClaudeDo.Worker.Runner;
+
+namespace ClaudeDo.Worker.Tests.Runner;
+
+public sealed class ClaudeArgsBuilderTests
+{
+ private readonly ClaudeArgsBuilder _builder = new();
+
+ [Fact]
+ public void Default_Config_Produces_Base_Args()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
+ Assert.Contains("-p", args);
+ Assert.Contains("--output-format stream-json", args);
+ Assert.Contains("--verbose", args);
+ Assert.Contains("--dangerously-skip-permissions", args);
+ Assert.Contains("--json-schema", args);
+ Assert.DoesNotContain("--model", args);
+ Assert.DoesNotContain("--append-system-prompt", args);
+ Assert.DoesNotContain("--agents", args);
+ Assert.DoesNotContain("--resume", args);
+ }
+
+ [Fact]
+ public void Model_Adds_Model_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig("sonnet-4-6", null, null, null));
+ Assert.Contains("--model sonnet-4-6", args);
+ }
+
+ [Fact]
+ public void SystemPrompt_Adds_Append_System_Prompt_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, "Be concise.", null, null));
+ Assert.Contains("--append-system-prompt", args);
+ Assert.Contains("Be concise.", args);
+ }
+
+ [Fact]
+ public void AgentPath_Adds_Agents_Flag_As_Json()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, "/path/to/agent.md", null));
+ Assert.Contains("--agents", args);
+ Assert.Contains("/path/to/agent.md", args);
+ }
+
+ [Fact]
+ public void ResumeSessionId_Adds_Resume_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, null, "sess-abc-123"));
+ Assert.Contains("--resume sess-abc-123", args);
+ }
+
+ [Fact]
+ public void All_Options_Set_Includes_All_Flags()
+ {
+ var args = _builder.Build(new ClaudeRunConfig("opus-4-6", "Be thorough.", "/agents/dev.md", "sess-xyz"));
+ Assert.Contains("--model opus-4-6", args);
+ Assert.Contains("--append-system-prompt", args);
+ Assert.Contains("--agents", args);
+ Assert.Contains("--resume sess-xyz", args);
+ Assert.Contains("--json-schema", args);
+ }
+
+ [Fact]
+ public void SystemPrompt_With_Quotes_Is_Escaped()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
+ Assert.Contains("--append-system-prompt", args);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs
deleted file mode 100644
index 56b4faf..0000000
--- a/tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using ClaudeDo.Worker.Runner;
-
-namespace ClaudeDo.Worker.Tests.Runner;
-
-public sealed class MessageParserTests
-{
- [Fact]
- public void WellFormed_Result_Line_Extracts_Result()
- {
- var line = """{"type":"result","result":"Hello **world**"}""";
- Assert.True(MessageParser.TryExtractResult(line, out var result));
- Assert.Equal("Hello **world**", result);
- }
-
- [Fact]
- public void Non_Result_Type_Returns_False()
- {
- var line = """{"type":"assistant","message":"hi"}""";
- Assert.False(MessageParser.TryExtractResult(line, out var result));
- Assert.Null(result);
- }
-
- [Fact]
- public void Missing_Type_Property_Returns_False()
- {
- var line = """{"result":"data"}""";
- Assert.False(MessageParser.TryExtractResult(line, out var result));
- Assert.Null(result);
- }
-
- [Fact]
- public void Malformed_Json_Returns_False_No_Throw()
- {
- var line = "this is not json {{{";
- Assert.False(MessageParser.TryExtractResult(line, out var result));
- Assert.Null(result);
- }
-
- [Fact]
- public void Empty_Line_Returns_False()
- {
- Assert.False(MessageParser.TryExtractResult("", out _));
- Assert.False(MessageParser.TryExtractResult(" ", out _));
- }
-
- [Fact]
- public void Null_Result_Value_Returns_True_With_Null()
- {
- var line = """{"type":"result","result":null}""";
- Assert.True(MessageParser.TryExtractResult(line, out var result));
- Assert.Null(result);
- }
-}
diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
new file mode 100644
index 0000000..009df99
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
@@ -0,0 +1,82 @@
+using ClaudeDo.Worker.Runner;
+
+namespace ClaudeDo.Worker.Tests.Runner;
+
+public sealed class StreamAnalyzerTests
+{
+ [Fact]
+ public void Extracts_Result_Markdown()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"result","result":"## Done","session_id":"sess-1"}""");
+ var result = analyzer.GetResult();
+ Assert.Equal("## Done", result.ResultMarkdown);
+ Assert.Equal("sess-1", result.SessionId);
+ }
+
+ [Fact]
+ public void Extracts_Structured_Output()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"result","result":"ok","structured_output":{"summary":"all good"},"session_id":"s1"}""");
+ var result = analyzer.GetResult();
+ Assert.Equal("ok", result.ResultMarkdown);
+ Assert.Contains("all good", result.StructuredOutputJson);
+ }
+
+ [Fact]
+ public void Counts_Assistant_Turns()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
+ analyzer.ProcessLine("""{"type":"assistant","message":"working on it"}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+ var result = analyzer.GetResult();
+ Assert.Equal(2, result.TurnCount);
+ }
+
+ [Fact]
+ public void Accumulates_Token_Usage()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":50}}}}""");
+ analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":200,"output_tokens":80}}}}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+ var result = analyzer.GetResult();
+ Assert.Equal(300, result.TokensIn);
+ Assert.Equal(130, result.TokensOut);
+ }
+
+ [Fact]
+ public void Counts_Api_Retry_Events()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":1,"error":"rate_limit"}""");
+ analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":2,"error":"rate_limit"}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+ var result = analyzer.GetResult();
+ Assert.Equal(2, result.ApiRetryCount);
+ }
+
+ [Fact]
+ public void Malformed_Json_Is_Ignored()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("not json {{{");
+ analyzer.ProcessLine("");
+ analyzer.ProcessLine(" ");
+ var result = analyzer.GetResult();
+ Assert.Null(result.ResultMarkdown);
+ Assert.Equal(0, result.TurnCount);
+ }
+
+ [Fact]
+ public void No_Result_Event_Returns_Null_Fields()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
+ var result = analyzer.GetResult();
+ Assert.Null(result.ResultMarkdown);
+ Assert.Null(result.SessionId);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs
new file mode 100644
index 0000000..d857da2
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs
@@ -0,0 +1,84 @@
+using ClaudeDo.Worker.Services;
+
+namespace ClaudeDo.Worker.Tests.Services;
+
+public sealed class AgentFileServiceTests : IDisposable
+{
+ private readonly string _agentDir;
+ private readonly AgentFileService _service;
+
+ public AgentFileServiceTests()
+ {
+ _agentDir = Path.Combine(Path.GetTempPath(), $"claudedo_agents_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_agentDir);
+ _service = new AgentFileService(_agentDir);
+ }
+
+ [Fact]
+ public async Task Scan_Returns_Empty_For_Empty_Directory()
+ {
+ var agents = await _service.ScanAsync();
+ Assert.Empty(agents);
+ }
+
+ [Fact]
+ public async Task Scan_Parses_Frontmatter()
+ {
+ var content = "---\nname: Test Agent\ndescription: A test agent for unit tests\n---\n\nYou are a test agent.";
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "test.md"), content);
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("Test Agent", agents[0].Name);
+ Assert.Equal("A test agent for unit tests", agents[0].Description);
+ Assert.EndsWith("test.md", agents[0].Path);
+ }
+
+ [Fact]
+ public async Task Scan_Uses_Filename_When_No_Frontmatter()
+ {
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "simple.md"), "Just instructions.");
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("simple", agents[0].Name);
+ Assert.Equal("", agents[0].Description);
+ }
+
+ [Fact]
+ public async Task Write_And_Read_Roundtrips()
+ {
+ var path = Path.Combine(_agentDir, "new-agent.md");
+ var content = "---\nname: New\ndescription: Desc\n---\nBody";
+ await _service.WriteAsync(path, content);
+
+ var read = await _service.ReadAsync(path);
+ Assert.Equal(content, read);
+ }
+
+ [Fact]
+ public async Task Delete_Removes_File()
+ {
+ var path = Path.Combine(_agentDir, "to-delete.md");
+ await File.WriteAllTextAsync(path, "temp");
+
+ await _service.DeleteAsync(path);
+ Assert.False(File.Exists(path));
+ }
+
+ [Fact]
+ public async Task Scan_Ignores_Non_Md_Files()
+ {
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "notes.txt"), "not an agent");
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "agent.md"), "---\nname: Real\ndescription: Yes\n---\nBody");
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("Real", agents[0].Name);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_agentDir, true); } catch { }
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
index 9a8ecb9..3d3d5c5 100644
--- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
@@ -43,13 +43,15 @@ public sealed class QueueServiceTests : IDisposable
}
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
- Func, CancellationToken, Task>? handler = null)
+ Func, CancellationToken, Task>? handler = null)
{
var fake = new FakeClaudeProcess(handler);
var broadcaster = new HubBroadcaster(new FakeHubContext());
var wtRepo = new WorktreeRepository(_db.Factory);
+ var runRepo = new TaskRunRepository(_db.Factory);
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger.Instance);
- var runner = new TaskRunner(fake, _taskRepo, _listRepo, broadcaster, wtManager, _cfg,
+ var argsBuilder = new ClaudeArgsBuilder();
+ var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger.Instance);
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger.Instance);
return (service, fake);
@@ -88,7 +90,7 @@ public sealed class QueueServiceTests : IDisposable
var (listId, _) = await SeedListWithAgentTag();
var tcs = new TaskCompletionSource();
- var (service, _) = CreateService((_, _, _, _, _, ct) => tcs.Task);
+ var (service, _) = CreateService((_, _, _, _, ct) => tcs.Task);
var task1 = await SeedQueuedTask(listId);
var task2 = await SeedQueuedTask(listId);
@@ -114,7 +116,7 @@ public sealed class QueueServiceTests : IDisposable
var (listId, _) = await SeedListWithAgentTag();
await SeedQueuedTask(listId, scheduledFor: DateTime.UtcNow.AddHours(1));
- var (service, fake) = CreateService((_, _, _, _, _, _) =>
+ var (service, fake) = CreateService((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
using var cts = new CancellationTokenSource();
@@ -139,17 +141,17 @@ public sealed class QueueServiceTests : IDisposable
var gate2 = new TaskCompletionSource();
var callCount = 0;
- var (service, _) = CreateService(async (prompt, _, _, taskId, _, ct) =>
+ var (service, _) = CreateService(async (_, _, _, _, ct) =>
{
var n = Interlocked.Increment(ref callCount);
- lock (order) { order.Add(taskId); }
+ lock (order) { order.Add(n.ToString()); }
if (n == 1) await gate1.Task;
if (n == 2) gate2.SetResult();
return new RunResult { ExitCode = 0, ResultMarkdown = "ok" };
});
- var task1 = await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-2));
- var task2 = await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-1));
+ await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-2));
+ await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-1));
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
@@ -162,7 +164,7 @@ public sealed class QueueServiceTests : IDisposable
// Only task1 should be running (task2 waiting on the queue slot).
Assert.Single(order);
- Assert.Equal(task1.Id, order[0]);
+ Assert.Equal("1", order[0]);
// Release first task.
gate1.SetResult();
@@ -171,7 +173,7 @@ public sealed class QueueServiceTests : IDisposable
await gate2.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(2, order.Count);
- Assert.Equal(task2.Id, order[1]);
+ Assert.Equal("2", order[1]);
cts.Cancel();
}
@@ -184,7 +186,7 @@ public sealed class QueueServiceTests : IDisposable
var running = new TaskCompletionSource();
var cancelled = false;
- var (service, _) = CreateService(async (_, _, _, _, _, ct) =>
+ var (service, _) = CreateService(async (_, _, _, _, ct) =>
{
running.SetResult();
try
@@ -211,13 +213,55 @@ public sealed class QueueServiceTests : IDisposable
Assert.True(cancelled);
}
+ [Fact]
+ public async Task RunNow_AutoRetries_On_Failure_With_SessionId()
+ {
+ var (listId, agentTagId) = await SeedListWithAgentTag();
+ var task = await SeedQueuedTask(listId);
+
+ var callCount = 0;
+ var (service, fake) = CreateService((prompt, dir, args, onLine, ct) =>
+ {
+ callCount++;
+ if (callCount == 1)
+ {
+ return Task.FromResult(new RunResult
+ {
+ ExitCode = 1,
+ ErrorMarkdown = "something broke",
+ SessionId = "sess-retry-test",
+ });
+ }
+ return Task.FromResult(new RunResult
+ {
+ ExitCode = 0,
+ ResultMarkdown = "fixed it",
+ SessionId = "sess-retry-test",
+ });
+ });
+
+ await service.StartAsync(CancellationToken.None);
+ await service.RunNow(task.Id);
+
+ // Wait for both runs to complete.
+ await Task.Delay(2000);
+
+ await service.StopAsync(CancellationToken.None);
+
+ Assert.Equal(2, callCount);
+
+ var finalTask = await _taskRepo.GetByIdAsync(task.Id);
+ Assert.NotNull(finalTask);
+ Assert.Equal(TaskStatus.Done, finalTask.Status);
+ }
+
[Fact]
public async Task GetActive_Returns_Running_Slots()
{
var (listId, _) = await SeedListWithAgentTag();
var tcs = new TaskCompletionSource();
- var (service, _) = CreateService((_, _, _, _, _, _) => tcs.Task);
+ var (service, _) = CreateService((_, _, _, _, _) => tcs.Task);
var task = await SeedQueuedTask(listId);
await service.RunNow(task.Id);
@@ -235,23 +279,23 @@ public sealed class QueueServiceTests : IDisposable
internal sealed class FakeClaudeProcess : IClaudeProcess
{
- private readonly Func, CancellationToken, Task> _handler;
+ private readonly Func, CancellationToken, Task> _handler;
private int _callCount;
public int CallCount => _callCount;
public FakeClaudeProcess(
- Func, CancellationToken, Task>? handler = null)
+ Func, CancellationToken, Task>? handler = null)
{
- _handler = handler ?? ((_, _, _, _, _, _) =>
+ _handler = handler ?? ((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
}
- public async Task RunAsync(string prompt, string workingDirectory, string logPath, string taskId,
+ public async Task RunAsync(string arguments, string prompt, string workingDirectory,
Func onStdoutLine, CancellationToken ct)
{
Interlocked.Increment(ref _callCount);
- return await _handler(prompt, workingDirectory, logPath, taskId, onStdoutLine, ct);
+ return await _handler(prompt, workingDirectory, arguments, onStdoutLine, ct);
}
}