diff --git a/src/ClaudeDo.Worker/Prime/PrimePrompts.cs b/src/ClaudeDo.Worker/Prime/PrimePrompts.cs
new file mode 100644
index 00000000..a1533344
--- /dev/null
+++ b/src/ClaudeDo.Worker/Prime/PrimePrompts.cs
@@ -0,0 +1,31 @@
+namespace ClaudeDo.Worker.Prime;
+
+/// Prompts and CLI args for the non-daily-prep Prime actions.
+/// The daily-prep ones live in .
+public static class PrimePrompts
+{
+ /// The whole point of a ping is to open the usage window, so it asks for the
+ /// cheapest possible answer. Not user-editable — a schedule's prompt override is ignored
+ /// for this kind.
+ public const string PingPrompt = "Reply with the single word: ok";
+
+ /// No tools at all: --strict-mcp-config without a --mcp-config argument loads no
+ /// MCP server, and one turn leaves no room to act on a tool result anyway.
+ public static IReadOnlyList BuildPingArgs() =>
+ [
+ "-p", "--output-format", "stream-json", "--verbose",
+ "--permission-mode", "default",
+ "--max-turns", "1",
+ "--strict-mcp-config",
+ ];
+
+ /// ClaudeDo's MCP server only — no Read/Write/Edit/Bash. An unattended 07:00 run
+ /// may create and queue tasks; it may not touch the filesystem.
+ public static IReadOnlyList BuildCustomArgs(int maxTurns) =>
+ [
+ "-p", "--output-format", "stream-json", "--verbose",
+ "--permission-mode", "acceptEdits",
+ "--max-turns", maxTurns.ToString(),
+ "--allowedTools", "mcp__claudedo",
+ ];
+}
diff --git a/src/ClaudeDo.Worker/Prime/PrimeRunner.cs b/src/ClaudeDo.Worker/Prime/PrimeRunner.cs
index 35d46bb6..91616c2e 100644
--- a/src/ClaudeDo.Worker/Prime/PrimeRunner.cs
+++ b/src/ClaudeDo.Worker/Prime/PrimeRunner.cs
@@ -1,4 +1,5 @@
using ClaudeDo.Data;
+using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
@@ -33,9 +34,27 @@ public sealed class PrimeRunner : IPrimeRunner
public async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
+ // One gate for all kinds — two schedules must never run at the same time.
if (!await _gate.WaitAsync(0, ct))
- return new PrimeRunOutcome(false, "Daily prep already running");
+ return new PrimeRunOutcome(false, "Prime run already running");
+ try
+ {
+ return schedule.Kind switch
+ {
+ PrimeActionKind.FillMyDay => await RunFillMyDayAsync(schedule, ct),
+ PrimeActionKind.Custom => await RunCustomAsync(schedule, ct),
+ _ => await RunPingAsync(ct),
+ };
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async Task RunFillMyDayAsync(PrimeScheduleDto schedule, CancellationToken ct)
+ {
var success = false;
try
{
@@ -45,8 +64,7 @@ public sealed class PrimeRunner : IPrimeRunner
await _broadcaster.PrepStartedAsync();
- var cwd = Paths.AppDataRoot();
- Directory.CreateDirectory(cwd);
+ var cwd = Cwd();
int maxTasks;
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
@@ -57,13 +75,12 @@ public sealed class PrimeRunner : IPrimeRunner
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today, schedule.PromptOverride);
- var args = DailyPrepPrompt.BuildArgs(MaxTurns);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(FireTimeout);
var result = await _claude.RunAsync(
- arguments: args,
+ arguments: DailyPrepPrompt.BuildArgs(MaxTurns),
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: async line =>
@@ -90,7 +107,57 @@ public sealed class PrimeRunner : IPrimeRunner
finally
{
await _broadcaster.PrepFinishedAsync(success);
- _gate.Release();
}
}
+
+ private Task RunPingAsync(CancellationToken ct) =>
+ RunQuietAsync(PrimePrompts.BuildPingArgs(), PrimePrompts.PingPrompt, "Ping complete", "Ping", ct);
+
+ private Task RunCustomAsync(PrimeScheduleDto schedule, CancellationToken ct)
+ {
+ var prompt = schedule.PromptOverride;
+ if (string.IsNullOrWhiteSpace(prompt))
+ return Task.FromResult(new PrimeRunOutcome(false, "Custom schedule has no prompt"));
+
+ return RunQuietAsync(PrimePrompts.BuildCustomArgs(MaxTurns), prompt, "Custom run complete", "Custom", ct);
+ }
+
+ /// Ping and Custom deliberately write no log file and raise no Prep* events — the
+ /// prep log belongs to the MyDay selection and would otherwise be overwritten with noise.
+ private async Task RunQuietAsync(
+ IReadOnlyList args, string prompt, string successMessage, string label, CancellationToken ct)
+ {
+ try
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeoutCts.CancelAfter(FireTimeout);
+
+ var result = await _claude.RunAsync(
+ arguments: args,
+ prompt: prompt,
+ workingDirectory: Cwd(),
+ onStdoutLine: _ => Task.CompletedTask,
+ ct: timeoutCts.Token);
+
+ return result.IsSuccess
+ ? new PrimeRunOutcome(true, successMessage)
+ : new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Prime {Label} run failed", label);
+ return new PrimeRunOutcome(false, ex.Message);
+ }
+ }
+
+ private static string Cwd()
+ {
+ var cwd = Paths.AppDataRoot();
+ Directory.CreateDirectory(cwd);
+ return cwd;
+ }
}
diff --git a/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs b/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs
index ec138255..0719eb99 100644
--- a/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs
+++ b/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs
@@ -1,3 +1,5 @@
+using ClaudeDo.Data.Models;
+
namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto(
@@ -6,4 +8,5 @@ public sealed record PrimeScheduleDto(
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
- string? PromptOverride);
+ string? PromptOverride,
+ PrimeActionKind Kind);
diff --git a/src/ClaudeDo.Worker/Prime/PrimeScheduler.cs b/src/ClaudeDo.Worker/Prime/PrimeScheduler.cs
index 2e7b2543..0229c13a 100644
--- a/src/ClaudeDo.Worker/Prime/PrimeScheduler.cs
+++ b/src/ClaudeDo.Worker/Prime/PrimeScheduler.cs
@@ -102,7 +102,7 @@ public sealed class PrimeScheduler : BackgroundService
}
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
- new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride);
+ new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride, e.Kind);
private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
diff --git a/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs
index e7acdc30..0e7b95f6 100644
--- a/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs
@@ -1,3 +1,4 @@
+using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -22,6 +23,9 @@ public class PrimeRunnerTests : IDisposable
private readonly string[] _emitLines;
private readonly string? _result;
+ public IReadOnlyList? LastArguments { get; private set; }
+ public string? LastPrompt { get; private set; }
+
public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null)
{
_delay = delay;
@@ -37,6 +41,9 @@ public class PrimeRunnerTests : IDisposable
Func onStdoutLine,
CancellationToken ct)
{
+ LastArguments = arguments;
+ LastPrompt = prompt;
+
if (_delay > TimeSpan.Zero)
await Task.Delay(_delay, ct);
@@ -72,7 +79,10 @@ public class PrimeRunnerTests : IDisposable
broadcaster);
private static PrimeScheduleDto DefaultSchedule() =>
- new(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
+ new(Guid.Empty, 0, TimeSpan.Zero, true, null, null, PrimeActionKind.FillMyDay);
+
+ private static PrimeScheduleDto Schedule(PrimeActionKind kind, string? promptOverride = null) =>
+ new(Guid.Empty, 0, TimeSpan.Zero, true, null, promptOverride, kind);
[Fact]
public async Task FireAsync_returns_success_when_claude_exits_zero()
@@ -114,7 +124,7 @@ public class PrimeRunnerTests : IDisposable
var broadcaster = new RecordingPrimeBroadcaster();
var claude = new FakeClaudeProcess(emitLines: ["{\"a\":1}", "{\"b\":2}"], exitCode: 0, result: "ok");
var runner = NewRunner(claude, broadcaster);
- var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
+ var schedule = DefaultSchedule();
var outcome = await runner.FireAsync(schedule, CancellationToken.None);
@@ -133,7 +143,7 @@ public class PrimeRunnerTests : IDisposable
var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok");
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
- await runner.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
+ await runner.FireAsync(DefaultSchedule(), CancellationToken.None);
var contents = await File.ReadAllTextAsync(path);
Assert.Contains("lineA", contents);
@@ -142,9 +152,105 @@ public class PrimeRunnerTests : IDisposable
// Truncation: a second run with different lines replaces the file.
var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok");
var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster());
- await runner2.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
+ await runner2.FireAsync(DefaultSchedule(), CancellationToken.None);
var after = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("lineA", after);
Assert.Contains("lineC", after);
}
+
+ [Fact]
+ public async Task Ping_runs_a_single_turn_with_no_mcp_servers()
+ {
+ var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
+ var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
+
+ var outcome = await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
+
+ Assert.True(outcome.Success);
+ Assert.Contains("--strict-mcp-config", claude.LastArguments!);
+ var turnsIndex = claude.LastArguments!.ToList().IndexOf("--max-turns");
+ Assert.Equal("1", claude.LastArguments[turnsIndex + 1]);
+ Assert.DoesNotContain(DailyPrepPrompt.SetMyDayTool, claude.LastArguments);
+ }
+
+ [Fact]
+ public async Task Ping_writes_no_prep_log_and_raises_no_prep_events()
+ {
+ var path = DailyPrepPrompt.LogPath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ await File.WriteAllTextAsync(path, "previous fill-my-day run");
+
+ var broadcaster = new RecordingPrimeBroadcaster();
+ var claude = new FakeClaudeProcess(emitLines: ["ping-line"], exitCode: 0, result: "ok");
+ var runner = NewRunner(claude, broadcaster);
+
+ await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
+
+ Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path));
+ Assert.Equal(0, broadcaster.StartedCount);
+ Assert.Empty(broadcaster.Lines);
+ Assert.Empty(broadcaster.FinishedResults);
+ }
+
+ [Fact]
+ public async Task Custom_sends_the_prompt_override_verbatim_with_claudedo_mcp_only()
+ {
+ var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
+ var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
+
+ var outcome = await runner.FireAsync(
+ Schedule(PrimeActionKind.Custom, "Queue the three oldest idle tasks."),
+ CancellationToken.None);
+
+ Assert.True(outcome.Success);
+ Assert.Equal("Queue the three oldest idle tasks.", claude.LastPrompt);
+ Assert.Contains("--allowedTools", claude.LastArguments!);
+ Assert.Contains("mcp__claudedo", claude.LastArguments!);
+ Assert.DoesNotContain("--strict-mcp-config", claude.LastArguments!);
+ }
+
+ [Fact]
+ public async Task Custom_writes_no_prep_log_and_raises_no_prep_events()
+ {
+ var path = DailyPrepPrompt.LogPath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ await File.WriteAllTextAsync(path, "previous fill-my-day run");
+
+ var broadcaster = new RecordingPrimeBroadcaster();
+ var claude = new FakeClaudeProcess(emitLines: ["custom-line"], exitCode: 0, result: "ok");
+ var runner = NewRunner(claude, broadcaster);
+
+ await runner.FireAsync(Schedule(PrimeActionKind.Custom, "do a thing"), CancellationToken.None);
+
+ Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path));
+ Assert.Equal(0, broadcaster.StartedCount);
+ Assert.Empty(broadcaster.FinishedResults);
+ }
+
+ [Fact]
+ public async Task FillMyDay_still_appends_the_prompt_override()
+ {
+ var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
+ var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
+
+ await runner.FireAsync(
+ Schedule(PrimeActionKind.FillMyDay, "Prefer the LogX tasks."),
+ CancellationToken.None);
+
+ Assert.Contains("Prefer the LogX tasks.", claude.LastPrompt);
+ Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.LastArguments!);
+ }
+
+ [Fact]
+ public async Task Gate_is_shared_across_kinds()
+ {
+ var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
+
+ var first = runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
+ var second = await runner.FireAsync(Schedule(PrimeActionKind.FillMyDay), CancellationToken.None);
+
+ Assert.False(second.Success);
+ Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
+ await first;
+ }
}