18 Commits

Author SHA1 Message Date
mika kuns
a135485339 docs(superpowers): add default-agents plan and design spec 2026-04-23 13:08:23 +02:00
mika kuns
3c420acd54 style(ui): polish list sidebar, kbd chips, and session terminal
- Introduce .list-count style for sidebar badges (brighter on active row).
- Bind list header More button to the correct OpenSettingsCommand.
- Give the per-list gear the standard icon-btn look.
- Center-align kbd chip content and title-bar/icon button content.
- Drop the kind-marker column in SessionTerminal and always show the
  scrollbar so the terminal feels like one.
2026-04-23 13:08:17 +02:00
mika kuns
5ced1b97a6 refactor(ui): redesign list settings and merge modals with custom chrome
Both modals now use SystemDecorations=None with a draggable title bar,
sectioned layout matching the rest of the island shell, Escape-to-cancel,
and themed brushes instead of hard-coded colours. ListSettings adds a
Browse... button that reads agent frontmatter from arbitrary .md files.
2026-04-23 13:08:09 +02:00
mika kuns
1344beba56 fix(ui): select task on left-click even when reorder is disabled
The tunnel pointer handler returned early when CanReorder was false,
so clicking a row in smart/virtual lists never updated the details
pane. Select first, then bail out of the drag path; also skip drag
initialisation on nested buttons so the done-toggle click still fires.
2026-04-23 13:08:02 +02:00
mika kuns
c8c8bb4a47 feat(ui): replay persisted task log when selecting a task
Read the task's LogPath on selection and feed each line through the
live-stream parser so Claude output stays visible across app restarts.
Tail-caps at 2000 lines to avoid flooding the UI.
2026-04-23 13:07:54 +02:00
mika kuns
6f725d12f5 feat(ui): add queueing and scheduling from task row context menu
- Right-click on a task row exposes Send to queue / Remove from queue
  and Schedule for... / Clear schedule actions.
- New virtual:queued list in the sidebar with live count.
- Sidebar counts are now computed (open per list, running, queued,
  review) and refreshed on task- and worker-side events.
- Sending a task to the queue wakes the worker so it starts immediately.
2026-04-23 13:07:48 +02:00
mika kuns
9952ff98f2 feat(ui): use ClaudeTask icon for window and taskbar
Expose the .ico as an Avalonia resource, set it on MainWindow, and
swap the custom SystemDecorations=None for BorderOnly so the icon
appears on the taskbar and window can be resized normally.
2026-04-23 13:07:38 +02:00
mika kuns
4a6d96b90e feat(installer): show version info and offer worker restart in settings
- Surface Latest version and flag unparseable pre-release tags in
  VersionLabel so users know why auto-update was skipped.
- Prompt to stop/start the worker service after Save, since the
  worker only reads its config at process start.
2026-04-23 13:07:31 +02:00
mika kuns
2690332d13 feat(installer): record data directory in install manifest
Write the resolved DbPath parent into the manifest so UninstallRunner
can honour customised data locations instead of always assuming
~/.todo-app. Older manifests fall back to the default path.
2026-04-23 13:07:23 +02:00
mika kuns
31218fc205 feat(installer): harden database init and service setup steps
- InitDatabaseStep: create DbPath parent directory so custom paths work
- RegisterServiceStep: pass obj= argument so ServiceAccount is honoured
- StartServiceStep: poll for RUNNING state so downstream steps don't race
2026-04-23 13:07:16 +02:00
mika kuns
cc01871407 chore(settings): allow context7 MCP tools 2026-04-23 13:07:07 +02:00
mika kuns
e70ae7f6ce feat(ui): add Restore default agents button to Settings modal 2026-04-23 12:21:02 +02:00
mika kuns
1830273a9d feat(ui): add RestoreDefaultAgentsAsync to WorkerClient 2026-04-23 12:19:44 +02:00
mika kuns
1a10e6fa09 feat(worker): expose RestoreDefaultAgents hub method 2026-04-23 12:18:49 +02:00
mika kuns
df57c2bc05 feat(worker): seed default agents on startup 2026-04-23 12:15:28 +02:00
mika kuns
990be09bd7 feat(worker): add DefaultAgentSeeder for first-launch agent seeding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:12:55 +02:00
mika kuns
e275f67a5e build(worker): ship DefaultAgents folder in build output 2026-04-23 12:09:13 +02:00
mika kuns
ff3de1d100 feat(worker): add bundled default agent definitions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:08:16 +02:00
47 changed files with 2287 additions and 202 deletions

View File

@@ -4,7 +4,9 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"mcp__plugin_context-mode_context-mode__batch_execute",
"mcp__plugin_context-mode_context-mode__execute"
"mcp__plugin_context-mode_context-mode__execute",
"mcp__plugin_context7_context7__query-docs",
"mcp__plugin_context-mode_context-mode__search"
]
}
}

View File

@@ -0,0 +1,852 @@
# Default Agents 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:** Ship ClaudeDo with 6 default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher) that seed into `~/.todo-app/agents/` on first launch, with a "Restore defaults" button in the settings modal.
**Architecture:** Bundled `.md` files in `src/ClaudeDo.Worker/DefaultAgents/` are copied to the Worker output folder. A new `DefaultAgentSeeder` service copies any missing file into the user's agents dir — run once at startup, and again on demand via a new `WorkerHub.RestoreDefaultAgents` method invoked by a button in `SettingsModalView`.
**Tech Stack:** .NET 8 / ASP.NET Core / SignalR / Avalonia 12 / CommunityToolkit.Mvvm / xUnit
---
## File Structure
**Create:**
- `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
- `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
- `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
- `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
- `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
- `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
- `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
- `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
**Modify:**
- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add Content item group for `DefaultAgents\*.md`
- `src/ClaudeDo.Worker/Program.cs` — register seeder, run `SeedMissingAsync()` once at startup
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — inject `DefaultAgentSeeder`, add `RestoreDefaultAgents` method
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `RestoreDefaultAgentsAsync` method + `SeedResultDto` record
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — add `RestoreDefaultAgentsCommand`
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — add button section
- `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs` — add seeder integration test
---
### Task 1: Bundle default agent markdown files
**Files:**
- Create: `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
- [ ] **Step 1: Write `code-reviewer.md`**
```markdown
---
name: code-reviewer
description: Reviews code changes for bugs, logic errors, and convention violations. Flags only high-confidence issues.
---
You are a code reviewer. Your job is to inspect the diff for real problems, not nitpicks.
Focus on:
- Logic errors, off-by-one bugs, null/empty handling
- Broken invariants, race conditions, resource leaks
- Violations of the project's established conventions (read nearby code first)
- Missing error handling at system boundaries (external input, IO, network)
Skip:
- Style preferences the codebase doesn't enforce
- Speculative "what if" concerns
- Renaming for its own sake
Output: a short list of concrete issues with file:line references. If the diff is clean, say so in one sentence. Do not rewrite the code — call out the problem and let the implementer fix it.
```
- [ ] **Step 2: Write `test-writer.md`**
```markdown
---
name: test-writer
description: Generates unit and integration tests for existing or new code. Follows the project's test patterns and frameworks.
---
You are a test-writer. Your job is to write focused, useful tests for code under review.
Process:
1. Read the target code and identify the observable behavior.
2. Read existing tests nearby to match the framework, fixtures, naming, and assertion style.
3. Write tests covering the happy path, boundary conditions, and the specific failure modes that matter.
Rules:
- One behavior per test. Clear Arrange/Act/Assert.
- No tests for private implementation details — exercise public API.
- No mocks where real objects are cheap (in-memory DBs, temp dirs).
- Skip trivially-correct tests (getter returns what you set).
Output: the test file(s) ready to compile, matching the project's conventions. Include the command to run them.
```
- [ ] **Step 3: Write `debugger.md`**
```markdown
---
name: debugger
description: Systematic root-cause analysis for bugs, test failures, and unexpected behavior. Hypothesize, isolate, verify.
---
You are a debugger. You do NOT guess at fixes — you find the root cause first.
Process:
1. Reproduce. Get a minimal, deterministic repro. If you can't reproduce it, say so and stop.
2. Isolate. Narrow the failing path (bisect, binary search, or tracing).
3. Hypothesize. State a specific, falsifiable cause.
4. Verify. Prove the hypothesis by observation (logs, debugger, targeted print) — not by "this seems likely".
5. Fix at the root, not the symptom. If the only fix is a workaround, explain why.
Anti-patterns to avoid:
- Making changes to "see if it works"
- Adding try/catch to silence errors
- Declaring the bug fixed without reproducing the fix
Output: repro steps, root cause, and the minimal fix. Include evidence (log excerpt, command output) that proves the cause.
```
- [ ] **Step 4: Write `security-reviewer.md`**
```markdown
---
name: security-reviewer
description: Audits code for OWASP-class security issues — auth, injection, input handling, secret exposure.
---
You are a security reviewer. Focus on real, exploitable weaknesses — not theoretical hardening.
Check for:
- Injection: SQL, command, path traversal, XSS, template injection
- Auth: missing authorization, token handling, session fixation
- Input validation at system boundaries (HTTP, files, IPC)
- Secrets: hardcoded credentials, tokens in logs, leaked env vars
- Unsafe deserialization, XXE, SSRF
- Cryptography misuse (custom crypto, weak algorithms, fixed IVs)
Ignore:
- Internal trust-boundary assumptions the project already documents
- Defense-in-depth ideas with no concrete attack path
Output: a prioritized list — severity, file:line, the exploit path, the fix. If nothing is wrong, say so plainly.
```
- [ ] **Step 5: Write `explorer.md`**
```markdown
---
name: explorer
description: Fast codebase navigation — find files, search for patterns, answer "where/how" questions. Terse output.
---
You are an explorer. Your job is to find things in the codebase quickly and report back concisely.
Use:
- Glob/Grep for searches
- Read only for files you need to quote from
Do NOT:
- Refactor, edit, or "improve" anything
- Read files that aren't relevant to the question
- Dump raw tool output — summarize
Output style:
- Lead with the answer in one sentence.
- Back it up with file:line references.
- If you found nothing, say "no match" and what you searched for.
Keep responses short. The caller wants facts, not prose.
```
- [ ] **Step 6: Write `researcher.md`**
```markdown
---
name: researcher
description: General-purpose research and analysis for non-code tasks — summarize docs, investigate questions, draft prose.
---
You are a researcher. You handle tasks that don't fit the code-review/test/debug shape.
Good fits:
- Summarizing documents, specs, or long outputs
- Investigating an open question (what does X do, how does Y work, what are the tradeoffs)
- Drafting non-code text (release notes, emails, docs)
- Analyzing structured data (logs, CSV, JSON) and reporting findings
Process:
1. Restate the task in one sentence so you know what "done" looks like.
2. Gather just enough information — stop when you can answer, not when you run out of sources.
3. Distinguish facts ("the file says X") from inference ("so likely Y").
4. Cite sources (file:line, URL, log excerpt) for every claim.
Output: direct answer first, supporting evidence second. Keep it short unless asked for depth.
```
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/DefaultAgents/
git commit -m "feat(worker): add bundled default agent definitions"
```
---
### Task 2: Wire bundled agents into build output
**Files:**
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- [ ] **Step 1: Add Content item group for DefaultAgents**
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`. After the existing `<ItemGroup>` blocks and before the final `<PropertyGroup>`, add:
```xml
<ItemGroup>
<Content Include="DefaultAgents\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```
- [ ] **Step 2: Build the worker and verify the files land in output**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
Then verify output:
Run: `ls src/ClaudeDo.Worker/bin/Debug/net8.0/DefaultAgents/`
Expected: all 6 `.md` files present.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
git commit -m "build(worker): ship DefaultAgents folder in build output"
```
---
### Task 3: Write DefaultAgentSeeder tests (failing)
**Files:**
- Create: `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
The service doesn't exist yet — these tests will fail to compile initially. That's fine; the next task implements it.
- [ ] **Step 1: Write the test file**
Create `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`:
```csharp
using ClaudeDo.Worker.Services;
namespace ClaudeDo.Worker.Tests.Services;
public sealed class DefaultAgentSeederTests : IDisposable
{
private readonly string _bundleDir;
private readonly string _targetDir;
public DefaultAgentSeederTests()
{
var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}");
_bundleDir = Path.Combine(root, "bundle");
_targetDir = Path.Combine(root, "target");
Directory.CreateDirectory(_bundleDir);
Directory.CreateDirectory(_targetDir);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { }
}
private async Task WriteBundleAsync(string name, string content)
{
await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content);
}
[Fact]
public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty()
{
await WriteBundleAsync("a.md", "A");
await WriteBundleAsync("b.md", "B");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(2, result.Copied);
Assert.Equal(0, result.Skipped);
Assert.True(File.Exists(Path.Combine(_targetDir, "a.md")));
Assert.True(File.Exists(Path.Combine(_targetDir, "b.md")));
}
[Fact]
public async Task SeedMissing_SkipsExistingFiles()
{
await WriteBundleAsync("a.md", "bundled");
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(0, result.Copied);
Assert.Equal(1, result.Skipped);
var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"));
Assert.Equal("user-modified", content);
}
[Fact]
public async Task SeedMissing_MixedState_CopiesOnlyMissing()
{
await WriteBundleAsync("a.md", "A");
await WriteBundleAsync("b.md", "B");
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.Equal(1, result.Skipped);
Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")));
Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md")));
}
[Fact]
public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing()
{
var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
var seeder = new DefaultAgentSeeder(missingBundle, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(0, result.Copied);
Assert.Equal(0, result.Skipped);
}
[Fact]
public async Task SeedMissing_CreatesTargetDir_IfMissing()
{
await WriteBundleAsync("a.md", "A");
var missingTarget = Path.Combine(_targetDir, "nested", "created");
var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.True(File.Exists(Path.Combine(missingTarget, "a.md")));
}
[Fact]
public async Task SeedMissing_IgnoresNonMarkdownFiles()
{
await WriteBundleAsync("a.md", "A");
await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt")));
}
}
```
- [ ] **Step 2: Run tests to confirm they fail to compile**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
Expected: compile error — `The type or namespace name 'DefaultAgentSeeder' could not be found`.
This confirms the tests target the not-yet-written service.
---
### Task 4: Implement DefaultAgentSeeder
**Files:**
- Create: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
- [ ] **Step 1: Write the service**
Create `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`:
```csharp
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Services;
public sealed record SeedResult(int Copied, int Skipped);
public sealed class DefaultAgentSeeder
{
private readonly string _bundleDir;
private readonly string _targetDir;
private readonly ILogger<DefaultAgentSeeder>? _logger;
public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger<DefaultAgentSeeder>? logger = null)
{
_bundleDir = bundleDir;
_targetDir = targetDir;
_logger = logger;
}
public async Task<SeedResult> SeedMissingAsync(CancellationToken ct = default)
{
if (!Directory.Exists(_bundleDir))
{
_logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir);
return new SeedResult(0, 0);
}
Directory.CreateDirectory(_targetDir);
int copied = 0;
int skipped = 0;
foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md"))
{
ct.ThrowIfCancellationRequested();
var fileName = Path.GetFileName(src);
var dst = Path.Combine(_targetDir, fileName);
if (File.Exists(dst))
{
skipped++;
continue;
}
try
{
using var input = File.OpenRead(src);
using var output = File.Create(dst);
await input.CopyToAsync(output, ct);
copied++;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName);
}
}
return new SeedResult(copied, skipped);
}
}
```
- [ ] **Step 2: Run the tests and verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
Expected: 6 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs
git commit -m "feat(worker): add DefaultAgentSeeder for first-launch agent seeding"
```
---
### Task 5: Wire seeder into Worker startup
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1: Register seeder and run SeedMissingAsync at startup**
In `src/ClaudeDo.Worker/Program.cs`, replace the "Agent file management." block (currently lines 3639):
Find:
```csharp
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
```
Replace with:
```csharp
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
defaultAgentsBundleDir,
agentsDir,
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
```
Then, after `var app = builder.Build();` and before `app.MapHub<WorkerHub>("/hub");`, add:
Find:
```csharp
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
app.MapHub<WorkerHub>("/hub");
```
Replace with:
```csharp
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
try
{
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
var seedResult = await seeder.SeedMissingAsync();
app.Logger.LogInformation(
"Default agents seeded: {Copied} copied, {Skipped} already present",
seedResult.Copied, seedResult.Skipped);
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Default agent seeding failed");
}
app.MapHub<WorkerHub>("/hub");
```
- [ ] **Step 2: Build worker to verify compile**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): seed default agents on startup"
```
---
### Task 6: Add RestoreDefaultAgents hub method (TDD)
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
The existing `AgentSettingsHubTests` doesn't exercise `WorkerHub` directly (it tests the repository). We'll add a new test file that tests the seeder restore flow end-to-end without SignalR plumbing — constructing the seeder with temp dirs and asserting the `SeedResult` round-trip. This mirrors how the file is structured today and keeps tests simple.
- [ ] **Step 1: Add a restore test**
Add to `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`. Inside the existing `AgentSettingsHubTests` class (before the closing brace), add:
```csharp
[Fact]
public async Task RestoreDefaultAgents_CopiesMissingBundledFiles()
{
var root = Path.Combine(Path.GetTempPath(), $"claudedo_hub_restore_{Guid.NewGuid():N}");
var bundleDir = Path.Combine(root, "bundle");
var targetDir = Path.Combine(root, "target");
try
{
Directory.CreateDirectory(bundleDir);
Directory.CreateDirectory(targetDir);
await File.WriteAllTextAsync(Path.Combine(bundleDir, "code-reviewer.md"), "body");
var seeder = new ClaudeDo.Worker.Services.DefaultAgentSeeder(bundleDir, targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.Equal(0, result.Skipped);
Assert.True(File.Exists(Path.Combine(targetDir, "code-reviewer.md")));
}
finally
{
try { Directory.Delete(root, true); } catch { }
}
}
```
- [ ] **Step 2: Run the test to confirm it passes (seeder already exists)**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentSettingsHubTests.RestoreDefaultAgents_CopiesMissingBundledFiles"`
Expected: 1 test passes.
- [ ] **Step 3: Add RestoreDefaultAgents to WorkerHub**
Edit `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
Add a new DTO record near the top with the other DTOs (after the `ListConfigDto` line on line 30):
```csharp
public record SeedResultDto(int Copied, int Skipped);
```
Update the class field block. Find:
```csharp
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService;
```
Replace with:
```csharp
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly DefaultAgentSeeder _seeder;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService;
```
Update the constructor. Find:
```csharp
public WorkerHub(
QueueService queue,
AgentFileService agentService,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
TaskMergeService mergeService)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
}
```
Replace with:
```csharp
public WorkerHub(
QueueService queue,
AgentFileService agentService,
DefaultAgentSeeder seeder,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
TaskMergeService mergeService)
{
_queue = queue;
_agentService = agentService;
_seeder = seeder;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
}
```
Then add the hub method. After the existing `RefreshAgents` method (currently line 126):
```csharp
public async Task<SeedResultDto> RestoreDefaultAgents()
{
var result = await _seeder.SeedMissingAsync();
return new SeedResultDto(result.Copied, result.Skipped);
}
```
- [ ] **Step 4: Build worker to verify compile**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 5: Run all Worker tests to confirm no regressions**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
git commit -m "feat(worker): expose RestoreDefaultAgents hub method"
```
---
### Task 7: Add RestoreDefaultAgentsAsync to WorkerClient
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add the DTO record**
At the bottom of `src/ClaudeDo.Ui/Services/WorkerClient.cs`, alongside the other public record declarations (after `ListConfigDto`, currently line 350), add:
```csharp
public sealed record SeedResultDto(int Copied, int Skipped);
```
- [ ] **Step 2: Add the client method**
In the `WorkerClient` class, after the `RefreshAgentsAsync` method (currently line 232), add:
```csharp
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
{
try
{
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
}
catch
{
return null;
}
}
```
- [ ] **Step 3: Build UI to verify compile**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add RestoreDefaultAgentsAsync to WorkerClient"
```
---
### Task 8: Add restore button to Settings modal
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- [ ] **Step 1: Add the command to the viewmodel**
In `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`, add a new command method. Place it after the existing `ConfirmResetAll` method (currently ending line 162), before the `OpenPath` method:
```csharp
[RelayCommand]
private async Task RestoreDefaultAgents()
{
IsBusy = true;
StatusMessage = "";
try
{
var result = await _worker.RestoreDefaultAgentsAsync();
if (result is null)
StatusMessage = "Worker offline.";
else if (result.Copied == 0 && result.Skipped == 0)
StatusMessage = "No default agents bundled.";
else if (result.Copied == 0)
StatusMessage = "All default agents already present.";
else
StatusMessage = $"Restored {result.Copied} default agent(s).";
await _worker.RefreshAgentsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Restore failed: {ex.Message}";
}
finally { IsBusy = false; }
}
```
- [ ] **Step 2: Add a button to the settings view**
Edit `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`. Add a new section after the `WORKTREES` StackPanel block (which ends with `</StackPanel>` around line 185) and before the `ABOUT` section (`<!-- ABOUT -->` around line 187).
Find:
```xml
</StackPanel>
<!-- ABOUT -->
```
Replace with:
```xml
</StackPanel>
<!-- AGENTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="AGENTS"/>
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"/>
<Button Content="Restore default agents"
Command="{Binding RestoreDefaultAgentsCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Left"/>
</StackPanel>
</Border>
</StackPanel>
<!-- ABOUT -->
```
- [ ] **Step 3: Build UI to verify compile**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Manual smoke test**
Start the worker and the UI. Open Settings (3-dots next to username). The AGENTS section should appear with a "Restore default agents" button.
1. With `~/.todo-app/agents/` empty (delete any existing `.md` files first, back them up if needed): click the button. Status should read "Restored N default agent(s)." The files should appear in the folder.
2. Click again. Status should read "All default agents already present."
3. Modify one of the restored files. Click restore. The modified file content should be preserved.
If any step fails, stop and fix before committing.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
git commit -m "feat(ui): add Restore default agents button to Settings modal"
```
---
## Verification
At the end, run the full test suite and build all projects:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet test tests/ClaudeDo.Worker.Tests
```
Expected: all builds succeed, all tests pass.
Additionally, start the Worker once with an empty `~/.todo-app/agents/` folder and confirm the log line:
> `Default agents seeded: 6 copied, 0 already present`
Then confirm `~/.todo-app/agents/` contains all 6 markdown files.

View File

@@ -0,0 +1,177 @@
# Default Agents — Design
**Date:** 2026-04-23
**Status:** Approved
## Goal
Ship ClaudeDo with a curated set of default agents so that users have useful agents available on first launch, without losing the file-based ownership model (user-editable, user-deletable). Provide a "Restore defaults" action to recover missing defaults on demand.
## Agents to Ship
Six markdown agents covering the common stages of task execution plus one general-purpose agent:
| File | Focus |
|---|---|
| `code-reviewer.md` | Review diff for bugs, logic errors, convention adherence. Flags only high-confidence issues. |
| `test-writer.md` | Generate unit/integration tests for changed code. Follows existing test patterns. |
| `debugger.md` | Systematic root-cause analysis — reproduce, isolate, hypothesize, verify. |
| `security-reviewer.md` | OWASP-style audit focused on auth, SQL injection, input handling, secret exposure. |
| `explorer.md` | Fast codebase navigation and answering "where/how" questions. Terse output. |
| `researcher.md` | General-purpose research, doc summarization, analysis, investigation. Non-code. |
Each file uses Claude Code's standard agent frontmatter:
```markdown
---
name: <agent name>
description: <one-line description>
---
<system prompt body>
```
Content target: ~2040 lines per file. Style matches the existing Claude Code agent conventions.
## Behavior
**Seed on first launch, restore on demand:**
1. Bundled agents live alongside the Worker binary at `<AppContext.BaseDirectory>/DefaultAgents/*.md`.
2. At Worker startup, for each bundled file: if `~/.todo-app/agents/<name>.md` does NOT exist, copy it in. If it exists, leave it alone — the user owns their copy.
3. A "Restore default agents" button in the settings modal re-runs the same check, restoring any that the user has deleted.
The seed path and the restore path are the same code — only the invocation differs (startup vs. hub call).
## Components
### `DefaultAgents/*.md` (bundled content)
Location: `src/ClaudeDo.Worker/DefaultAgents/`
Packaging: `<Content Include="DefaultAgents\*.md" CopyToOutputDirectory="PreserveNewest" />` in `ClaudeDo.Worker.csproj`.
At runtime they land at `<AppContext.BaseDirectory>/DefaultAgents/*.md` next to the executable.
### `DefaultAgentSeeder` (new service)
Location: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
```
public sealed class DefaultAgentSeeder
{
public DefaultAgentSeeder(string bundleDir, string targetDir);
public Task<SeedResult> SeedMissingAsync(CancellationToken ct = default);
}
public sealed record SeedResult(int Copied, int Skipped);
```
Behavior of `SeedMissingAsync`:
- If `bundleDir` doesn't exist, log warning and return `(0, 0)`.
- Enumerate `*.md` in `bundleDir`.
- For each file, if the target path (`targetDir/<filename>`) is missing, copy it; else increment `Skipped`.
- Create `targetDir` if missing (consistent with existing `AgentFileService.WriteAsync`).
- Per-file exceptions are caught and logged; the seeder continues with the next file. The method itself does not throw for individual file failures.
### `Program.cs` wiring
After `AgentFileService` registration and before `app.Run()`:
```csharp
var bundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
var seeder = new DefaultAgentSeeder(bundleDir, agentsDir);
await seeder.SeedMissingAsync();
builder.Services.AddSingleton(seeder);
```
The seeder is also registered as a singleton so the hub can invoke it for the restore flow.
### `WorkerHub.RestoreDefaultAgents`
Location: add method to existing `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
Signature: `public async Task<SeedResult> RestoreDefaultAgents()`
Behavior:
- Calls `DefaultAgentSeeder.SeedMissingAsync()`.
- Returns the `SeedResult` to the caller.
- No separate broadcast — the UI will call `GetAgents` after the restore returns, reusing the existing refresh path.
### UI — Settings Modal
Location: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` + `SettingsModalViewModel`.
Add a "Restore default agents" button to the modal. On click:
1. Disable the button, show a spinner label.
2. Call `WorkerClient.RestoreDefaultAgentsAsync()`.
3. Show a brief inline confirmation: `"Restored {Copied} agent(s)"` or `"All defaults already present"`.
4. Trigger the existing agent list refresh so the new files appear immediately in the rest of the UI.
### `WorkerClient` method
Add `Task<SeedResult> RestoreDefaultAgentsAsync(CancellationToken ct = default)` to `WorkerClient` — thin wrapper that invokes the hub method.
## Data Flow
**Startup:**
```
Worker starts → DefaultAgentSeeder.SeedMissingAsync()
→ copies missing files into ~/.todo-app/agents/
AgentFileService.ScanAsync() (on first GetAgents call) → sees the seeded files
```
**User restores:**
```
Settings modal button click
→ WorkerClient.RestoreDefaultAgentsAsync()
→ WorkerHub.RestoreDefaultAgents()
→ DefaultAgentSeeder.SeedMissingAsync()
→ returns SeedResult(copied, skipped)
UI shows confirmation, triggers GetAgents refresh
```
## Error Handling
| Failure | Behavior |
|---|---|
| Missing `DefaultAgents/` bundle dir | Log warning, return `(0, 0)`. Startup proceeds. |
| Individual file copy failure (disk, permissions) | Catch per-file, log, continue with the remaining files. |
| Corrupt bundled markdown (no valid frontmatter) | Copied anyway — the `AgentFileService` frontmatter parser already falls back to filename-as-name. |
| Startup seeder exception (unexpected) | Log as warning, do not crash the Worker. Agents can still be restored via the button. |
| Hub `RestoreDefaultAgents` exception | Propagate to client as SignalR error; UI shows a generic "Restore failed" message. |
## Testing
**Unit:** `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
- Seeds all files when target dir is empty.
- Skips files that already exist.
- Preserves existing user-modified files (file mtime / content unchanged).
- Returns accurate `SeedResult` counts.
- Handles missing bundle dir gracefully (returns `(0, 0)`, no throw).
- Creates target dir if it doesn't exist.
**Integration:** extend `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
- `RestoreDefaultAgents` invokes the seeder and returns the count.
**No UI tests.** The project has no UI test harness; settings modal behavior is exercised manually.
## Build / Packaging
`src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` gains:
```xml
<ItemGroup>
<Content Include="DefaultAgents\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```
No new NuGet dependencies.
## Out of Scope
- Editing bundled agents in-place (user edits their copy under `~/.todo-app/agents/`; bundle is read-only by convention).
- Versioning / updating user copies when the bundled version changes. If a bundled agent is improved in a later release, the user's copy is not overwritten. A future release may add a "diff / reset to bundled" flow, but not now.
- Packaging as embedded resources. Content files copied to output are simpler, inspectable on disk, and consistent with the file-based agent model.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -88,7 +88,9 @@ sealed class Program
sp,
sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),

View File

@@ -50,6 +50,7 @@ public partial class App : Application
context.Mode = state.Mode;
context.InstalledVersion = state.Existing?.Version;
context.LatestVersion = state.LatestVersion;
context.LatestTagUnparseable = state.LatestTagUnparseable;
if (state.Existing is not null)
context.InstallDirectory = state.Existing.InstallDir;

View File

@@ -10,6 +10,7 @@ public sealed class InstallContext
public string? InstallerVersion { get; set; } // from this installer's assembly
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
public bool LatestTagUnparseable { get; set; } // true if latest tag isn't a System.Version
// PathsPage
public string DbPath { get; set; } = "~/.todo-app/todo.db";

View File

@@ -8,7 +8,8 @@ public sealed record InstallManifest(
string Version,
string InstallDir,
string WorkerDir,
DateTimeOffset InstalledAt);
DateTimeOffset InstalledAt,
string? DataDir = null);
public static class InstallManifestStore
{

View File

@@ -4,7 +4,12 @@ public sealed record DetectedState(
InstallerMode Mode,
InstallManifest? Existing,
GiteaRelease? LatestRelease,
string? LatestVersion);
string? LatestVersion)
{
/// <summary>True when a release was returned but its tag isn't a parseable
/// System.Version (e.g. "0.2.0-beta") — so we couldn't decide if it's newer.</summary>
public bool LatestTagUnparseable { get; init; }
}
public sealed class InstallModeDetector
{
@@ -26,23 +31,26 @@ public sealed class InstallModeDetector
return new DetectedState(InstallerMode.Config, manifest, null, null);
var latestVersion = release.TagName.TrimStart('v', 'V');
if (IsNewer(latestVersion, manifest.Version))
var newer = IsNewer(latestVersion, manifest.Version, out var unparseable);
if (newer)
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion)
{
LatestTagUnparseable = unparseable,
};
}
/// <summary>
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
/// AND latest &gt; current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
/// treated as "not newer" — the user drops into Config mode with no update offered.
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
/// If the project starts shipping pre-release tags, revisit this.
/// treated as "not newer" — the user drops into Config mode with no update offered, but
/// <paramref name="unparseable"/> is set so the UI can surface a hint.
/// </summary>
private static bool IsNewer(string latest, string current)
private static bool IsNewer(string latest, string current, out bool unparseable)
{
if (!Version.TryParse(latest, out var lv)) return false;
if (!Version.TryParse(current, out var cv)) return false;
unparseable = !Version.TryParse(latest, out var lv) | !Version.TryParse(current, out var cv);
if (unparseable) return false;
return lv > cv;
}
}

View File

@@ -67,10 +67,13 @@ public sealed class UninstallRunner
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
}
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
// 6) Delete data dir (config + DB + logs) — only if user opted in.
// Prefer the manifest-recorded DataDir so a customised DbPath is honoured;
// fall back to the default ~/.todo-app for older manifests.
if (removeAppData)
{
var appData = Paths.AppDataRoot();
var manifest = InstallManifestStore.TryRead(_context.InstallDirectory);
var appData = manifest?.DataDir ?? Paths.AppDataRoot();
if (Directory.Exists(appData))
{
progress.Report($"Deleting {appData}...");

View File

@@ -1,3 +1,4 @@
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
using Microsoft.EntityFrameworkCore;
@@ -15,6 +16,10 @@ public sealed class InitDatabaseStep : IInstallStep
var expandedPath = Paths.Expand(ctx.DbPath);
progress.Report($"Initializing database at {expandedPath}");
var parent = Path.GetDirectoryName(expandedPath);
if (!string.IsNullOrEmpty(parent))
Directory.CreateDirectory(parent);
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={expandedPath}")
.Options;

View File

@@ -43,13 +43,21 @@ public sealed class RegisterServiceStep : IInstallStep
// Create service
var startType = ctx.AutoStart ? "auto" : "demand";
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
if (ctx.ServiceAccount == "CurrentUser")
return StepResult.Fail(
"Service cannot run as Current User without a password. " +
"Select 'Local System' or extend ServicePage to capture a password.");
var objArg = ctx.ServiceAccount switch
{
"LocalSystem" => " obj= LocalSystem",
"NetworkService" => " obj= \"NT AUTHORITY\\NetworkService\"",
"LocalService" => " obj= \"NT AUTHORITY\\LocalService\"",
_ => "",
};
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}{objArg}";
progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode == 1072)

View File

@@ -13,15 +13,26 @@ public sealed class StartServiceStep : IInstallStep
progress.Report($"Starting {ServiceName}...");
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
if (exit == 0) return StepResult.Ok();
// 1056 = ERROR_SERVICE_ALREADY_RUNNING — fine, fall through to the readiness poll.
if (exit != 0 && exit != 1056)
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
if (exit == 1056)
{
progress.Report("Service was already running.");
return StepResult.Ok();
// sc.exe start returns as soon as SCM accepts the command. Poll until the
// service actually reports RUNNING so downstream steps and SignalR clients
// don't race the worker's startup.
progress.Report("Waiting for service to reach RUNNING state...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, output) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (q == 0 && output.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
return StepResult.Ok();
await Task.Delay(1000, ct);
}
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
return StepResult.Fail("Service did not reach RUNNING state within 30 seconds.");
}
}

View File

@@ -1,4 +1,5 @@
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
@@ -14,11 +15,14 @@ public sealed class WriteInstallManifestStep : IInstallStep
try
{
var dataDir = Path.GetDirectoryName(Paths.Expand(ctx.DbPath));
var manifest = new InstallManifest(
Version: ctx.InstalledVersion,
InstallDir: ctx.InstallDirectory,
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
InstalledAt: DateTimeOffset.UtcNow);
InstalledAt: DateTimeOffset.UtcNow,
DataDir: dataDir);
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");

View File

@@ -50,7 +50,12 @@ public partial class SettingsViewModel : ObservableObject
_uninstallRunner = uninstallRunner;
_selectedPage = Pages.FirstOrDefault();
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
var label = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
if (!string.IsNullOrEmpty(context.LatestVersion))
label += $" Latest: {context.LatestVersion}";
if (context.LatestTagUnparseable)
label += " (pre-release tag — auto-update disabled)";
VersionLabel = label;
_ = LoadAllAsync();
}
@@ -98,8 +103,39 @@ public partial class SettingsViewModel : ObservableObject
};
uiCfg.Save();
StatusMessage = "Settings saved.";
IsStatusError = false;
StatusMessage = "Settings saved.";
// Worker reads its config at process start, so changes only take effect after a restart.
var restart = MessageBox.Show(
"Restart the worker service now so the new settings take effect?",
"Restart Worker",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (restart != MessageBoxResult.Yes)
{
StatusMessage = "Settings saved. Restart the worker service manually to apply.";
return;
}
var progress = new Progress<string>(msg => StatusMessage = msg);
var stop = await _stopService.ExecuteAsync(_context, progress, CancellationToken.None);
if (!stop.Success)
{
StatusMessage = $"Saved, but worker stop failed: {stop.ErrorMessage}";
IsStatusError = true;
return;
}
var start = await _startService.ExecuteAsync(_context, progress, CancellationToken.None);
if (!start.Success)
{
StatusMessage = $"Saved, but worker start failed: {start.ErrorMessage}";
IsStatusError = true;
return;
}
StatusMessage = "Settings saved. Worker restarted.";
}
[RelayCommand]

View File

@@ -22,6 +22,7 @@
<AvaloniaResource Include="Assets/Fonts/*.ttf" />
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
<AvaloniaResource Include="..\ClaudeDo.App\Assets\ClaudeTask.ico" Link="Assets\ClaudeTask.ico" />
</ItemGroup>
</Project>

View File

@@ -95,6 +95,8 @@
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.title-ctrl:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
@@ -221,6 +223,8 @@
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
@@ -539,6 +543,20 @@
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Count badge — larger, high contrast, brighter when the row is active -->
<Style Selector="TextBlock.list-count">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="8,0,4,0" />
</Style>
<Style Selector="Border.list-item.active TextBlock.list-count">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- ============================================================ -->
<!-- LIST SECTION HEADER -->
<!-- ============================================================ -->
@@ -582,16 +600,21 @@
<!-- KBD CHIP -->
<!-- ============================================================ -->
<Style Selector="Border.kbd">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="6,2" />
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="Border.kbd > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Center" />
</Style>
<!-- ============================================================ -->

View File

@@ -231,6 +231,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("RefreshAgents");
}
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
{
try
{
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
}
catch
{
return null;
}
}
private async Task SeedActiveTasksAsync()
{
try
@@ -348,3 +360,4 @@ public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, s
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
public sealed record SeedResultDto(int Copied, int Skipped);

View File

@@ -378,6 +378,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Subscribe only after DB load confirms the task exists
_subscribedTaskId = row.Id;
// Replay the latest run's persisted log so output is visible across app restarts.
await ReplayLogFileAsync(entity.LogPath, ct);
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
ct.ThrowIfCancellationRequested();
foreach (var s in subs)
@@ -386,6 +389,59 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
catch (OperationCanceledException) { }
}
private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(logPath)) return;
var expanded = ExpandUserPath(logPath);
if (!System.IO.File.Exists(expanded)) return;
try
{
const int maxLines = 2000;
string[] all;
await using (var fs = new System.IO.FileStream(
expanded,
System.IO.FileMode.Open,
System.IO.FileAccess.Read,
System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
using (var reader = new System.IO.StreamReader(fs))
{
var list = new List<string>();
while (await reader.ReadLineAsync(ct) is { } line)
list.Add(line);
all = list.ToArray();
}
ct.ThrowIfCancellationRequested();
var start = Math.Max(0, all.Length - maxLines);
for (int i = start; i < all.Length; i++)
{
ct.ThrowIfCancellationRequested();
if (_subscribedTaskId is null) return;
// Worker writes raw Claude CLI stdout to disk (no prefix) but broadcasts
// it with a "[stdout] " prefix. Match the live-stream format so the same
// stream-json parser handles both.
var line = all[i];
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
OnTaskMessage(_subscribedTaskId, normalized);
}
FlushClaudeBuffer();
}
catch (OperationCanceledException) { throw; }
catch { /* best-effort replay */ }
}
private static string ExpandUserPath(string path)
{
if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
return System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
path[2..]);
if (path == "~")
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return path;
}
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
{
try

View File

@@ -2,6 +2,8 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
@@ -68,7 +70,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
: Environment.UserName.ToUpperInvariant();
if (_worker is not null)
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
{
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
}
}
public async Task LoadAsync(CancellationToken ct = default)
@@ -82,6 +89,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = "virtual:queued", Name = "Queue", Kind = ListKind.Virtual, IconKey = "Inbox" },
new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" },
};
@@ -116,8 +124,46 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
public async Task RefreshCountsAsync(CancellationToken ct = default)
{
foreach (var i in Items) i.Count = 0;
await Task.CompletedTask;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping.
var open = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status != TaskStatus.Done)
.Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
.ToListAsync(ct);
var running = open.Count(t => t.Status == TaskStatus.Running);
var queued = open.Count(t => t.Status == TaskStatus.Queued);
var review = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == TaskStatus.Done && t.Worktree != null && t.Worktree.State == WorktreeState.Active)
.CountAsync(ct);
foreach (var item in SmartLists)
{
item.Count = item.Id switch
{
"smart:my-day" => open.Count(t => t.IsMyDay),
"smart:important" => open.Count(t => t.IsStarred),
"smart:planned" => open.Count(t => t.Scheduled != null),
"virtual:queued" => queued,
"virtual:running" => running,
"virtual:review" => review,
_ => 0,
};
}
foreach (var item in UserLists)
{
var listId = item.Id.StartsWith("user:", StringComparison.Ordinal)
? item.Id["user:".Length..]
: item.Id;
item.Count = open.Count(t => t.ListId == listId);
}
}
catch (OperationCanceledException) { throw; }
catch { /* best-effort refresh */ }
}
[RelayCommand]

View File

@@ -37,6 +37,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool HasSteps => StepsCount > 0;
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
public bool IsRunning => Status == TaskStatus.Running;
public bool IsQueued => Status == TaskStatus.Queued;
public bool HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
public string DiffAdditionsText => $"+{DiffAdditions}";
@@ -56,13 +58,18 @@ public sealed partial class TaskRowViewModel : ViewModelBase
{
OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(HasLiveTail));
}
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
partial void OnScheduledForChanged(DateTime? value) => OnPropertyChanged(nameof(IsOverdue));
partial void OnScheduledForChanged(DateTime? value)
{
OnPropertyChanged(nameof(IsOverdue));
OnPropertyChanged(nameof(HasSchedule));
}
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }

View File

@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -12,11 +13,13 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TasksIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient? _worker;
private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts;
public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged;
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
@@ -38,9 +41,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
[ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = "COMPLETED";
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
{
_dbFactory = dbFactory;
_worker = worker;
}
public void LoadForList(ListNavItemViewModel? list)
@@ -85,6 +89,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => t.Status == TaskStatus.Queued),
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
@@ -170,6 +175,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
Regroup();
NewTaskTitle = "";
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
public bool CanReorder => _currentList?.Kind == ListKind.User;
@@ -199,15 +205,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
if (source.IsRunning || target.IsRunning) return;
if (ReferenceEquals(source, target)) return;
var srcIdx = Items.IndexOf(source);
var tgtIdx = Items.IndexOf(target);
if (srcIdx < 0 || tgtIdx < 0) return;
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
MoveWithinCollection(Items, source, target, placeBelow);
Items.RemoveAt(srcIdx);
var newTgtIdx = Items.IndexOf(target);
var insertIdx = placeBelow ? newTgtIdx + 1 : newTgtIdx;
if (insertIdx < 0 || insertIdx > Items.Count) insertIdx = Items.Count;
Items.Insert(insertIdx, source);
// Apply the same move in whichever section the row lives in.
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
// that's determined by Done flag and ScheduledFor date, not drag-drop.
var sourceSection = SectionFor(source);
var targetSection = SectionFor(target);
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
MoveWithinCollection(sourceSection, source, target, placeBelow);
var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList();
@@ -223,8 +230,33 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
if (e is not null) e.SortOrder = i;
}
await db.SaveChangesAsync();
}
Regroup();
private static void MoveWithinCollection(
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
TaskRowViewModel source,
TaskRowViewModel target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
var tgtIdx = coll.IndexOf(target);
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
if (srcIdx < finalIdx) finalIdx--;
if (finalIdx < 0) finalIdx = 0;
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
if (finalIdx == srcIdx) return;
coll.Move(srcIdx, finalIdx);
}
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
{
if (OverdueItems.Contains(row)) return OverdueItems;
if (OpenItems.Contains(row)) return OpenItems;
if (CompletedItems.Contains(row)) return CompletedItems;
return null;
}
[RelayCommand]
@@ -241,6 +273,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
@@ -254,8 +287,61 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
entity.IsStarred = row.IsStarred;
await db.SaveChangesAsync();
}
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
if (row is null || row.IsRunning) return;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
entity.Status = TaskStatus.Queued;
await db.SaveChangesAsync();
row.Status = TaskStatus.Queued;
if (_worker is not null)
{
try { await _worker.WakeQueueAsync(); } catch { }
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
{
if (row is null) return;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
entity.Status = TaskStatus.Manual;
await db.SaveChangesAsync();
row.Status = TaskStatus.Manual;
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
{
if (row is null) return;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
entity.ScheduledFor = when;
await db.SaveChangesAsync();
row.ScheduledFor = when;
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private Task ClearScheduleAsync(TaskRowViewModel? row) =>
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
[RelayCommand]
private void Select(TaskRowViewModel row) => SelectedTask = row;

View File

@@ -52,10 +52,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.DeleteFromList = _ =>
Details.DeleteFromList = row =>
{
Tasks.LoadForList(Lists.SelectedList);
_ = Lists.RefreshCountsAsync();
return System.Threading.Tasks.Task.CompletedTask;
};
Worker.PropertyChanged += (_, e) =>

View File

@@ -161,6 +161,32 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
finally { IsBusy = false; }
}
[RelayCommand]
private async Task RestoreDefaultAgents()
{
IsBusy = true;
StatusMessage = "";
try
{
var result = await _worker.RestoreDefaultAgentsAsync();
if (result is null)
StatusMessage = "Worker offline.";
else if (result.Copied == 0 && result.Skipped == 0)
StatusMessage = "No default agents bundled.";
else if (result.Copied == 0)
StatusMessage = "All default agents already present.";
else
StatusMessage = $"Restored {result.Copied} default agent(s).";
await _worker.RefreshAgentsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Restore failed: {ex.Message}";
}
finally { IsBusy = false; }
}
[RelayCommand]
private void OpenPath(string? path)
{

View File

@@ -66,7 +66,7 @@
</StackPanel>
<!-- More button -->
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"
Command="{Binding OpenListSettingsCommand}"
Command="{Binding OpenSettingsCommand}"
ToolTip.Tip="Settings">
<PathIcon Data="{StaticResource Icon.MoreHorizontal}"
Width="14" Height="14"
@@ -112,11 +112,8 @@
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
<!-- Count -->
<TextBlock Grid.Column="2"
Text="{Binding Count}"
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
</DataTemplate>
@@ -160,20 +157,13 @@
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
<!-- Count -->
<TextBlock Grid.Column="2"
Text="{Binding Count}"
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/>
<!-- Gear button -->
<Button Grid.Column="3"
<Button Grid.Column="3" Classes="icon-btn"
Content="⚙"
FontSize="12"
ToolTip.Tip="Settings..."
Background="Transparent"
BorderThickness="0"
Padding="4,0"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>

View File

@@ -50,23 +50,20 @@
</Grid>
<!-- ── Log output ── -->
<ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto"
<ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible"
AllowAutoHide="False"
Padding="10,8,10,12">
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,46,*" Margin="0,1">
<Grid ColumnDefinitions="60,*" Margin="0,1">
<!-- Timestamp -->
<TextBlock Grid.Column="0"
Classes="log-ts"
Text="{Binding TimestampFormatted}"/>
<!-- Kind marker -->
<TextBlock Grid.Column="1"
Classes="log-kind"
Tag="{Binding ClassName}"
Text="{Binding KindMarker}"/>
<!-- Message text — selectable so the user can copy raw output -->
<SelectableTextBlock Grid.Column="2"
<SelectableTextBlock Grid.Column="1"
Text="{Binding Text}" Tag="{Binding ClassName}"
FontFamily="{DynamicResource MonoFont}" FontSize="11"
Foreground="{DynamicResource TextDimBrush}"

View File

@@ -19,11 +19,22 @@
Margin="0"
Classes.selected="{Binding IsSelected}"
Classes.done="{Binding Done}">
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
<!-- Left accent bar (visible when selected) -->
<Border Grid.Column="0" Classes="task-row-accent"
IsVisible="{Binding IsSelected}"/>
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Send to queue"
IsVisible="{Binding !IsQueued}"
Click="OnSendToQueueClick"/>
<MenuItem Header="Remove from queue"
IsVisible="{Binding IsQueued}"
Click="OnRemoveFromQueueClick"/>
<Separator/>
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
<MenuItem Header="Clear schedule"
IsVisible="{Binding HasSchedule}"
Click="OnClearScheduleClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,32,*,32" Margin="6,8,10,8">
<!-- Done toggle -->
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top"
@@ -53,6 +64,15 @@
<TextBlock Text="{Binding Status}"/>
</Border>
<!-- Dequeue button (only when Queued) -->
<Button Classes="icon-btn dequeue-btn"
IsVisible="{Binding IsQueued}"
ToolTip.Tip="Remove from queue"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
CommandParameter="{Binding}">
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
</Button>
<!-- List chip with dot -->
<Border Classes="chip chip-list">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
@@ -129,5 +149,45 @@
<Border Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"/>
</Grid>
<!-- Hidden schedule anchor (its Flyout is shown from the context menu) -->
<Button Grid.Row="1" x:Name="ScheduleAnchor"
Width="1" Height="1" Opacity="0"
HorizontalAlignment="Left" VerticalAlignment="Top"
IsHitTestVisible="False" Focusable="False">
<Button.Flyout>
<Flyout Placement="Bottom" ShowMode="Standard">
<Border Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1" CornerRadius="10"
Padding="16" Width="300">
<StackPanel Spacing="12">
<TextBlock Text="Schedule task"
FontWeight="SemiBold" FontSize="13"
Foreground="{DynamicResource TextBrush}"/>
<StackPanel Spacing="6">
<TextBlock Text="DATE" FontSize="10" Opacity="0.6"
Foreground="{DynamicResource TextDimBrush}"/>
<DatePicker x:Name="ScheduleDate" HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="TIME" FontSize="10" Opacity="0.6"
Foreground="{DynamicResource TextDimBrush}"/>
<TimePicker x:Name="ScheduleTime" ClockIdentifier="24HourClock"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,4,0,0">
<Button Content="Cancel" Click="OnScheduleCancelClick" MinWidth="76"/>
<Button Content="Schedule" Classes="accent" Click="OnScheduleSetClick" MinWidth="76"/>
</StackPanel>
</StackPanel>
</Border>
</Flyout>
</Button.Flyout>
</Button>
</Grid>
</UserControl>

View File

@@ -1,16 +1,72 @@
using System.Linq;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands;
public partial class TaskRowView : UserControl
{
private TaskRowViewModel? _pendingScheduleRow;
public TaskRowView() { InitializeComponent(); }
private TasksIslandViewModel? FindTasksVm() =>
this.GetVisualAncestors().OfType<ItemsControl>()
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
private async void OnSendToQueueClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.SendToQueueCommand.ExecuteAsync(row);
}
private async void OnRemoveFromQueueClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
}
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ClearScheduleCommand.ExecuteAsync(row);
}
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel row) return;
_pendingScheduleRow = row;
var seed = row.ScheduledFor ?? DateTime.Now.AddHours(1);
ScheduleDate.SelectedDate = new DateTimeOffset(seed.Date, TimeSpan.Zero);
ScheduleTime.SelectedTime = seed.TimeOfDay;
ScheduleAnchor.Flyout?.ShowAt(ScheduleAnchor);
}
private async void OnScheduleSetClick(object? sender, RoutedEventArgs e)
{
ScheduleAnchor.Flyout?.Hide();
if (_pendingScheduleRow is null || ScheduleDate.SelectedDate is null) return;
var date = ScheduleDate.SelectedDate.Value.Date;
var time = ScheduleTime.SelectedTime ?? TimeSpan.FromHours(9);
var when = date + time;
if (FindTasksVm() is { } tvm)
await tvm.SetScheduledForAsync(_pendingScheduleRow, when);
_pendingScheduleRow = null;
}
private void OnScheduleCancelClick(object? sender, RoutedEventArgs e)
{
ScheduleAnchor.Flyout?.Hide();
_pendingScheduleRow = null;
}
protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);

View File

@@ -15,8 +15,6 @@ public partial class TasksIslandView : UserControl
public TasksIslandView()
{
InitializeComponent();
// Tunnel handler runs BEFORE Button's class handler so we can start a drag
// without the Button first marking the event as handled.
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
DataContextChanged += (_, _) =>
{
@@ -27,14 +25,25 @@ public partial class TasksIslandView : UserControl
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm || !vm.CanReorder) return;
if (DataContext is not TasksIslandViewModel vm) return;
if (e.Source is not Visual src) return;
var button = src as Button ?? src.FindAncestorOfType<Button>();
if (button?.DataContext is not TaskRowViewModel row) return;
if (row.IsRunning) return;
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
// Select now so the details pane updates whether the gesture becomes a click or a drag.
// (Button.Click doesn't fire once DoDragDropAsync captures the pointer.)
vm.SelectedTask = row;
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
// don't start a drag — that would capture the pointer and swallow the inner Click.
var nestedInsideButton = button.Parent is Visual parentVisual
&& parentVisual.FindAncestorOfType<Button>() is not null;
if (nestedInsideButton) return;
if (!vm.CanReorder || row.IsRunning) return;
var data = new DataTransfer();
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
try
@@ -133,11 +142,18 @@ public partial class TasksIslandView : UserControl
if (source is null || source.IsRunning) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
// Clear the 6px drop-hint spacer BEFORE the move so the reorder animates
// into its truly-final layout in one step (otherwise the row lands in the
// gap, then the gap collapses and everything shifts up a second time).
vm.ClearDropHints();
await vm.ReorderAsync(source, target, placeBelow);
}
finally
catch
{
vm.ClearDropHints();
throw;
}
}

View File

@@ -7,7 +7,9 @@
Title="ClaudeDo"
Width="1280" Height="820" MinWidth="780" MinHeight="600"
Background="{DynamicResource VoidBrush}"
SystemDecorations="None"
Icon="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico"
CanResize="True"
SystemDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1">
<Window.KeyBindings>
@@ -26,11 +28,11 @@
<!-- Left: brand block -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center" Margin="14,0,0,0">
<!-- Green checkbox glyph -->
<PathIcon Classes="title-brand-icon"
Data="{StaticResource Icon.BrandCheck}"
Width="14" Height="14"
Foreground="{DynamicResource MossBrush}" />
<!-- App icon (matches taskbar) -->
<Image Source="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico"
Width="16" Height="16"
VerticalAlignment="Center"
RenderOptions.BitmapInterpolationMode="HighQuality"/>
<!-- CLAUDEDO label -->
<TextBlock Classes="title-brand-name"
Text="CLAUDEDO"

View File

@@ -4,77 +4,177 @@
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
x:DataType="vm:ListSettingsModalViewModel"
Title="List settings"
Width="520" Height="600"
Width="520" Height="720"
CanResize="True"
MinWidth="460" MinHeight="520"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
CanResize="False">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" />
<Button Content="Save" Command="{Binding SaveCommand}" Classes="accent" />
</StackPanel>
Background="{DynamicResource SurfaceBrush}">
<ScrollViewer>
<StackPanel Spacing="16">
<TextBlock Text="General" FontSize="16" FontWeight="SemiBold" />
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="TextBlock.section-label">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="LetterSpacing" Value="1.4"/>
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}"/>
<Setter Property="Margin" Value="4,0,0,6"/>
</Style>
<Style Selector="TextBlock.field-label">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<Style Selector="Border.section">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="14"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</Window.Styles>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1">
<Grid RowDefinitions="36,*,52">
<!-- Title bar -->
<Border Grid.Row="0"
x:Name="TitleBar"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="TitleBar_PointerPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="LIST SETTINGS"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CancelCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Body -->
<ScrollViewer Grid.Row="1" Padding="20,16">
<StackPanel Spacing="18">
<!-- GENERAL -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="GENERAL"/>
<Border Classes="section">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="Name" />
<TextBox Text="{Binding Name}" />
<TextBlock Classes="field-label" Text="Name"/>
<TextBox Text="{Binding Name}" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Working directory" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="(none)" />
<Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" />
</Grid>
<TextBlock Classes="field-label" Text="Working directory"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" Watermark="(none)" />
<Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" />
</Grid>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Default commit type" />
<ComboBox ItemsSource="{Binding CommitTypeOptions}"
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" />
<TextBlock Classes="field-label" Text="Default commit type"/>
<ComboBox ItemsSource="{Binding CommitTypeOptions}"
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
<Separator Margin="0,8,0,8" />
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Agent" FontSize="16" FontWeight="SemiBold" />
<Button Grid.Column="1" Content="Reset agent settings"
Command="{Binding ResetAgentSettingsCommand}" />
</Grid>
<!-- AGENT -->
<StackPanel Spacing="0">
<Grid ColumnDefinitions="*,Auto" Margin="4,0,0,6">
<TextBlock Classes="section-label" Text="AGENT" Margin="0"/>
<Button Grid.Column="1" Content="Reset agent settings"
Command="{Binding ResetAgentSettingsCommand}" />
</Grid>
<Border Classes="section">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="Model" />
<ComboBox ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" />
<TextBlock Classes="field-label" Text="Model"/>
<ComboBox ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="System prompt (appended)" />
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="80" />
<TextBlock Classes="field-label" Text="System prompt (appended)"/>
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="80" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Agent file" />
<ComboBox ItemsSource="{Binding Agents}"
<TextBlock Classes="field-label" Text="Agent file"/>
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0"
ItemsSource="{Binding Agents}"
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="240">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Description}" Opacity="0.6" FontSize="11" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Name}"
Foreground="{DynamicResource TextBrush}"/>
<TextBlock Text="{Binding Description}"
Foreground="{DynamicResource TextMuteBrush}"
FontSize="11" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1" Content="Browse..."
Margin="8,0,0,0" Click="BrowseAgentClicked" />
</Grid>
<TextBlock Text="{Binding SelectedAgent.Path}"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
TextTrimming="PrefixCharacterEllipsis"
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" MinWidth="90"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -1,6 +1,8 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
@@ -12,6 +14,63 @@ public partial class ListSettingsModalView : Window
InitializeComponent();
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
private async void BrowseAgentClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not ListSettingsModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choose agent file",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Agent files (*.md)") { Patterns = new[] { "*.md" } },
new FilePickerFileType("All files") { Patterns = new[] { "*" } },
},
});
if (files.Count == 0) return;
var path = files[0].Path.LocalPath;
var existing = vm.Agents.FirstOrDefault(a => string.Equals(a.Path, path, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
vm.SelectedAgent = existing;
return;
}
var (name, description) = ReadFrontmatter(path);
var agent = new AgentInfo(name, description, path);
vm.Agents.Add(agent);
vm.SelectedAgent = agent;
}
private static (string name, string description) ReadFrontmatter(string filePath)
{
var fallback = System.IO.Path.GetFileNameWithoutExtension(filePath);
try
{
using var reader = new System.IO.StreamReader(filePath);
if (reader.ReadLine()?.Trim() != "---") return (fallback, "");
string name = fallback, 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();
}
return (name, description);
}
catch { return (fallback, ""); }
}
private async void BrowseClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not ListSettingsModalViewModel vm) return;

View File

@@ -4,77 +4,135 @@
x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
x:DataType="vm:MergeModalViewModel"
Title="Merge worktree"
Width="560" Height="420"
Width="560" Height="460"
CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<TextBlock Grid.Row="0"
Text="{Binding TaskTitle, StringFormat='Merging: {0}'}"
FontWeight="SemiBold" Margin="0,0,0,12" />
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<StackPanel Grid.Row="1" Orientation="Vertical" Margin="0,0,0,8">
<TextBlock Text="Target branch" Margin="0,0,0,4" />
<ComboBox ItemsSource="{Binding Branches}"
SelectedItem="{Binding SelectedBranch}"
HorizontalAlignment="Stretch"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<Window.Styles>
<Style Selector="TextBlock.field-label">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</Window.Styles>
<CheckBox Grid.Row="2"
Content="Remove worktree after merge"
IsChecked="{Binding RemoveWorktree}"
IsEnabled="{Binding !IsBusy}"
Margin="0,0,0,8" />
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1">
<Grid RowDefinitions="36,*,52">
<StackPanel Grid.Row="3" Orientation="Vertical" Margin="0,0,0,8">
<TextBlock Text="Commit message" Margin="0,0,0,4" />
<TextBox Text="{Binding CommitMessage}"
AcceptsReturn="True"
TextWrapping="Wrap"
Height="70"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<!-- Title bar -->
<Border Grid.Row="0"
x:Name="TitleBar"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="TitleBar_PointerPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="MERGE WORKTREE"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CancelCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<TextBlock Grid.Row="4"
Text="{Binding ErrorMessage}"
Foreground="IndianRed"
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,0,8" />
<!-- Body -->
<ScrollViewer Grid.Row="1" Padding="20,16">
<StackPanel Spacing="12">
<Border Grid.Row="5"
BorderBrush="IndianRed"
BorderThickness="1"
Padding="8"
IsVisible="{Binding HasConflict}">
<StackPanel>
<TextBlock Text="Conflicted files:" FontWeight="SemiBold" Margin="0,0,0,4" />
<ItemsControl ItemsSource="{Binding ConflictFiles}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Text="{Binding TaskTitle, StringFormat='Merging: {0}'}"
FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}" />
<StackPanel Grid.Row="6" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,12,0,0">
<TextBlock Text="{Binding SuccessMessage}"
Foreground="SeaGreen"
VerticalAlignment="Center"
Margin="0,0,12,0"
IsVisible="{Binding SuccessMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Cancel"
Command="{Binding CancelCommand}"
Margin="0,0,8,0" />
<Button Content="Merge"
Command="{Binding SubmitCommand}"
IsDefault="True"
Classes="accent" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Target branch"/>
<ComboBox ItemsSource="{Binding Branches}"
SelectedItem="{Binding SelectedBranch}"
HorizontalAlignment="Stretch"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
</Grid>
<CheckBox Content="Remove worktree after merge"
IsChecked="{Binding RemoveWorktree}"
IsEnabled="{Binding !IsBusy}" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Commit message"/>
<TextBox Text="{Binding CommitMessage}"
AcceptsReturn="True"
TextWrapping="Wrap"
Height="70"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Border BorderBrush="{DynamicResource BloodBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="12,10"
IsVisible="{Binding HasConflict}">
<StackPanel Spacing="4">
<TextBlock Text="Conflicted files:"
FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}" />
<ItemsControl ItemsSource="{Binding ConflictFiles}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
Foreground="{DynamicResource TextDimBrush}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Text="{Binding SuccessMessage}"
Foreground="{DynamicResource MossBrightBrush}"
IsVisible="{Binding SuccessMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Merge" Classes="primary"
Command="{Binding SubmitCommand}"
IsDefault="True" MinWidth="90"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
@@ -16,4 +17,10 @@ public partial class MergeModalView : Window
}
};
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
}

View File

@@ -184,6 +184,23 @@
</Border>
</StackPanel>
<!-- AGENTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="AGENTS"/>
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"/>
<Button Content="Restore default agents"
Command="{Binding RestoreDefaultAgentsCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Left"/>
</StackPanel>
</Border>
</StackPanel>
<!-- ABOUT -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="ABOUT"/>

View File

@@ -9,6 +9,12 @@
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<Content Include="DefaultAgents\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>

View File

@@ -0,0 +1,19 @@
---
name: code-reviewer
description: Reviews code changes for bugs, logic errors, and convention violations. Flags only high-confidence issues.
---
You are a code reviewer. Your job is to inspect the diff for real problems, not nitpicks.
Focus on:
- Logic errors, off-by-one bugs, null/empty handling
- Broken invariants, race conditions, resource leaks
- Violations of the project's established conventions (read nearby code first)
- Missing error handling at system boundaries (external input, IO, network)
Skip:
- Style preferences the codebase doesn't enforce
- Speculative "what if" concerns
- Renaming for its own sake
Output: a short list of concrete issues with file:line references. If the diff is clean, say so in one sentence. Do not rewrite the code — call out the problem and let the implementer fix it.

View File

@@ -0,0 +1,20 @@
---
name: debugger
description: Systematic root-cause analysis for bugs, test failures, and unexpected behavior. Hypothesize, isolate, verify.
---
You are a debugger. You do NOT guess at fixes — you find the root cause first.
Process:
1. Reproduce. Get a minimal, deterministic repro. If you can't reproduce it, say so and stop.
2. Isolate. Narrow the failing path (bisect, binary search, or tracing).
3. Hypothesize. State a specific, falsifiable cause.
4. Verify. Prove the hypothesis by observation (logs, debugger, targeted print) — not by "this seems likely".
5. Fix at the root, not the symptom. If the only fix is a workaround, explain why.
Anti-patterns to avoid:
- Making changes to "see if it works"
- Adding try/catch to silence errors
- Declaring the bug fixed without reproducing the fix
Output: repro steps, root cause, and the minimal fix. Include evidence (log excerpt, command output) that proves the cause.

View File

@@ -0,0 +1,22 @@
---
name: explorer
description: Fast codebase navigation — find files, search for patterns, answer "where/how" questions. Terse output.
---
You are an explorer. Your job is to find things in the codebase quickly and report back concisely.
Use:
- Glob/Grep for searches
- Read only for files you need to quote from
Do NOT:
- Refactor, edit, or "improve" anything
- Read files that aren't relevant to the question
- Dump raw tool output — summarize
Output style:
- Lead with the answer in one sentence.
- Back it up with file:line references.
- If you found nothing, say "no match" and what you searched for.
Keep responses short. The caller wants facts, not prose.

View File

@@ -0,0 +1,20 @@
---
name: researcher
description: General-purpose research and analysis for non-code tasks — summarize docs, investigate questions, draft prose.
---
You are a researcher. You handle tasks that don't fit the code-review/test/debug shape.
Good fits:
- Summarizing documents, specs, or long outputs
- Investigating an open question (what does X do, how does Y work, what are the tradeoffs)
- Drafting non-code text (release notes, emails, docs)
- Analyzing structured data (logs, CSV, JSON) and reporting findings
Process:
1. Restate the task in one sentence so you know what "done" looks like.
2. Gather just enough information — stop when you can answer, not when you run out of sources.
3. Distinguish facts ("the file says X") from inference ("so likely Y").
4. Cite sources (file:line, URL, log excerpt) for every claim.
Output: direct answer first, supporting evidence second. Keep it short unless asked for depth.

View File

@@ -0,0 +1,20 @@
---
name: security-reviewer
description: Audits code for OWASP-class security issues — auth, injection, input handling, secret exposure.
---
You are a security reviewer. Focus on real, exploitable weaknesses — not theoretical hardening.
Check for:
- Injection: SQL, command, path traversal, XSS, template injection
- Auth: missing authorization, token handling, session fixation
- Input validation at system boundaries (HTTP, files, IPC)
- Secrets: hardcoded credentials, tokens in logs, leaked env vars
- Unsafe deserialization, XXE, SSRF
- Cryptography misuse (custom crypto, weak algorithms, fixed IVs)
Ignore:
- Internal trust-boundary assumptions the project already documents
- Defense-in-depth ideas with no concrete attack path
Output: a prioritized list — severity, file:line, the exploit path, the fix. If nothing is wrong, say so plainly.

View File

@@ -0,0 +1,19 @@
---
name: test-writer
description: Generates unit and integration tests for existing or new code. Follows the project's test patterns and frameworks.
---
You are a test-writer. Your job is to write focused, useful tests for code under review.
Process:
1. Read the target code and identify the observable behavior.
2. Read existing tests nearby to match the framework, fixtures, naming, and assertion style.
3. Write tests covering the happy path, boundary conditions, and the specific failure modes that matter.
Rules:
- One behavior per test. Clear Arrange/Act/Assert.
- No tests for private implementation details — exercise public API.
- No mocks where real objects are cheap (in-memory DBs, temp dirs).
- Skip trivially-correct tests (getter returns what you set).
Output: the test file(s) ready to compile, matching the project's conventions. Include the command to run them.

View File

@@ -28,6 +28,7 @@ public record UpdateListDto(string Id, string Name, string? WorkingDir, string D
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
public record SeedResultDto(int Copied, int Skipped);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
@@ -36,6 +37,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly DefaultAgentSeeder _seeder;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance;
@@ -45,6 +47,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public WorkerHub(
QueueService queue,
AgentFileService agentService,
DefaultAgentSeeder seeder,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
@@ -53,6 +56,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
_queue = queue;
_agentService = agentService;
_seeder = seeder;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
@@ -125,6 +129,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public async Task RefreshAgents() => await _agentService.ScanAsync();
public async Task<SeedResultDto> RestoreDefaultAgents()
{
var result = await _seeder.SeedMissingAsync();
return new SeedResultDto(result.Copied, result.Skipped);
}
public async Task<AppSettingsDto> GetAppSettings()
{
using var ctx = _dbFactory.CreateDbContext();

View File

@@ -38,6 +38,12 @@ var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
defaultAgentsBundleDir,
agentsDir,
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
// QueueService: singleton + hosted service (same instance).
builder.Services.AddSingleton<QueueService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
@@ -53,6 +59,19 @@ using (var scope = app.Services.CreateScope())
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
try
{
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
var seedResult = await seeder.SeedMissingAsync();
app.Logger.LogInformation(
"Default agents seeded: {Copied} copied, {Skipped} already present",
seedResult.Copied, seedResult.Skipped);
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Default agent seeding failed");
}
app.MapHub<WorkerHub>("/hub");
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",

View File

@@ -0,0 +1,60 @@
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Services;
public sealed record SeedResult(int Copied, int Skipped);
public sealed class DefaultAgentSeeder
{
private readonly string _bundleDir;
private readonly string _targetDir;
private readonly ILogger<DefaultAgentSeeder>? _logger;
public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger<DefaultAgentSeeder>? logger = null)
{
_bundleDir = bundleDir;
_targetDir = targetDir;
_logger = logger;
}
public async Task<SeedResult> SeedMissingAsync(CancellationToken ct = default)
{
if (!Directory.Exists(_bundleDir))
{
_logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir);
return new SeedResult(0, 0);
}
Directory.CreateDirectory(_targetDir);
int copied = 0;
int skipped = 0;
foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md"))
{
ct.ThrowIfCancellationRequested();
var fileName = Path.GetFileName(src);
var dst = Path.Combine(_targetDir, fileName);
if (File.Exists(dst))
{
skipped++;
continue;
}
try
{
using var input = File.OpenRead(src);
using var output = File.Create(dst);
await input.CopyToAsync(output, ct);
copied++;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName);
}
}
return new SeedResult(copied, skipped);
}
}

View File

@@ -44,4 +44,29 @@ public sealed class AgentSettingsHubTests : IDisposable
Assert.Null(await _repo.GetConfigAsync(listId));
}
[Fact]
public async Task RestoreDefaultAgents_CopiesMissingBundledFiles()
{
var root = Path.Combine(Path.GetTempPath(), $"claudedo_hub_restore_{Guid.NewGuid():N}");
var bundleDir = Path.Combine(root, "bundle");
var targetDir = Path.Combine(root, "target");
try
{
Directory.CreateDirectory(bundleDir);
Directory.CreateDirectory(targetDir);
await File.WriteAllTextAsync(Path.Combine(bundleDir, "code-reviewer.md"), "body");
var seeder = new ClaudeDo.Worker.Services.DefaultAgentSeeder(bundleDir, targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.Equal(0, result.Skipped);
Assert.True(File.Exists(Path.Combine(targetDir, "code-reviewer.md")));
}
finally
{
try { Directory.Delete(root, true); } catch { }
}
}
}

View File

@@ -0,0 +1,112 @@
using ClaudeDo.Worker.Services;
namespace ClaudeDo.Worker.Tests.Services;
public sealed class DefaultAgentSeederTests : IDisposable
{
private readonly string _bundleDir;
private readonly string _targetDir;
public DefaultAgentSeederTests()
{
var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}");
_bundleDir = Path.Combine(root, "bundle");
_targetDir = Path.Combine(root, "target");
Directory.CreateDirectory(_bundleDir);
Directory.CreateDirectory(_targetDir);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { }
}
private async Task WriteBundleAsync(string name, string content)
{
await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content);
}
[Fact]
public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty()
{
await WriteBundleAsync("a.md", "A");
await WriteBundleAsync("b.md", "B");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(2, result.Copied);
Assert.Equal(0, result.Skipped);
Assert.True(File.Exists(Path.Combine(_targetDir, "a.md")));
Assert.True(File.Exists(Path.Combine(_targetDir, "b.md")));
}
[Fact]
public async Task SeedMissing_SkipsExistingFiles()
{
await WriteBundleAsync("a.md", "bundled");
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(0, result.Copied);
Assert.Equal(1, result.Skipped);
var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"));
Assert.Equal("user-modified", content);
}
[Fact]
public async Task SeedMissing_MixedState_CopiesOnlyMissing()
{
await WriteBundleAsync("a.md", "A");
await WriteBundleAsync("b.md", "B");
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.Equal(1, result.Skipped);
Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")));
Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md")));
}
[Fact]
public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing()
{
var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
var seeder = new DefaultAgentSeeder(missingBundle, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(0, result.Copied);
Assert.Equal(0, result.Skipped);
}
[Fact]
public async Task SeedMissing_CreatesTargetDir_IfMissing()
{
await WriteBundleAsync("a.md", "A");
var missingTarget = Path.Combine(_targetDir, "nested", "created");
var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.True(File.Exists(Path.Combine(missingTarget, "a.md")));
}
[Fact]
public async Task SeedMissing_IgnoresNonMarkdownFiles()
{
await WriteBundleAsync("a.md", "A");
await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt")));
}
}