diff --git a/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs b/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
index 301b931..5d0f072 100644
--- a/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
+++ b/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
@@ -1,4 +1,6 @@
using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -8,6 +10,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260416064948_InitialCreate")]
public partial class InitialCreate : Migration
{
///
diff --git a/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs b/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs
index 79ba1ac..27b2fe8 100644
--- a/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs
+++ b/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs
@@ -1,10 +1,14 @@
-using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260420075929_AddTaskFlagsAndNotes")]
public partial class AddTaskFlagsAndNotes : Migration
{
///
diff --git a/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs b/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs
index 083c4cb..99eef30 100644
--- a/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs
+++ b/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs
@@ -1,10 +1,14 @@
-using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260421113614_AddAppSettings")]
public partial class AddAppSettings : Migration
{
///
diff --git a/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs b/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs
index 6918635..3f275bd 100644
--- a/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs
+++ b/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs
@@ -1,3 +1,5 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260422120000_AddTaskSortOrder")]
public partial class AddTaskSortOrder : Migration
{
///
diff --git a/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs b/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs
index ef24f78..9efb30a 100644
--- a/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs
+++ b/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs
@@ -1,4 +1,6 @@
using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -6,6 +8,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260423154708_AddPlanningSupport")]
public partial class AddPlanningSupport : Migration
{
///
diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs
index d746589..232f00d 100644
--- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs
+++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs
@@ -267,6 +267,23 @@ public sealed class TaskRepository
return child;
}
+ public async Task UpdatePlanningTaskAsync(
+ string taskId,
+ string? title,
+ string? description,
+ CancellationToken ct = default)
+ {
+ var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct)
+ ?? throw new InvalidOperationException("Planning task not found.");
+ if (title is not null) entity.Title = title;
+ if (description is not null) entity.Description = description;
+ await _context.Tasks
+ .Where(t => t.Id == taskId)
+ .ExecuteUpdateAsync(s => s
+ .SetProperty(t => t.Title, entity.Title)
+ .SetProperty(t => t.Description, entity.Description), ct);
+ }
+
public async Task SetPlanningStartedAsync(
string taskId,
string sessionToken,
diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
index 25e8539..5f937f6 100644
--- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
@@ -7,6 +7,8 @@
+
+
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index 57ba89c..0371cee 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -2,6 +2,7 @@ using System.Reflection;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
@@ -43,6 +44,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService;
+ private readonly PlanningSessionManager _planning;
+ private readonly IPlanningTerminalLauncher _launcher;
public WorkerHub(
QueueService queue,
@@ -52,7 +55,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
IDbContextFactory dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
- TaskMergeService mergeService)
+ TaskMergeService mergeService,
+ PlanningSessionManager planning,
+ IPlanningTerminalLauncher launcher)
{
_queue = queue;
_agentService = agentService;
@@ -62,6 +67,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
+ _planning = planning;
+ _launcher = launcher;
}
public string Ping() => $"pong v{Version}";
@@ -284,5 +291,44 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
await _broadcaster.TaskUpdated(dto.TaskId);
}
+ public async Task StartPlanningSessionAsync(string taskId)
+ {
+ var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
+ try
+ {
+ await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted);
+ }
+ catch (PlanningLaunchException)
+ {
+ await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
+ throw;
+ }
+ await Clients.All.SendAsync("TaskUpdated", taskId);
+ return ctx;
+ }
+
+ public async Task ResumePlanningSessionAsync(string taskId)
+ {
+ var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
+ await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted);
+ return ctx;
+ }
+
+ public async Task DiscardPlanningSessionAsync(string taskId)
+ {
+ await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
+ await Clients.All.SendAsync("TaskUpdated", taskId);
+ }
+
+ public async Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
+ {
+ var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted);
+ await Clients.All.SendAsync("TaskUpdated", taskId);
+ return count;
+ }
+
+ public Task GetPendingDraftCountAsync(string taskId)
+ => _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
+
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}
diff --git a/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
new file mode 100644
index 0000000..ddbe016
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
@@ -0,0 +1,12 @@
+namespace ClaudeDo.Worker.Planning;
+
+public interface IPlanningTerminalLauncher
+{
+ Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
+ Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
+}
+
+public sealed class PlanningLaunchException : Exception
+{
+ public PlanningLaunchException(string message) : base(message) { }
+}
diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs
new file mode 100644
index 0000000..bf29dcd
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs
@@ -0,0 +1,6 @@
+namespace ClaudeDo.Worker.Planning;
+
+public sealed class PlanningMcpContext
+{
+ public required string ParentTaskId { get; init; }
+}
diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs
new file mode 100644
index 0000000..98bb220
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Http;
+
+namespace ClaudeDo.Worker.Planning;
+
+public sealed class PlanningMcpContextAccessor
+{
+ private readonly IHttpContextAccessor _http;
+
+ public PlanningMcpContextAccessor(IHttpContextAccessor http) => _http = http;
+
+ public PlanningMcpContext Current =>
+ (_http.HttpContext?.Items["PlanningContext"] as PlanningMcpContext)
+ ?? throw new InvalidOperationException("No planning context on request.");
+}
diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
new file mode 100644
index 0000000..302fe08
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
@@ -0,0 +1,128 @@
+using System.ComponentModel;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Hub;
+using ModelContextProtocol.Server;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Planning;
+
+public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList Tags);
+public sealed record CreatedChildDto(string TaskId, string Status);
+
+[McpServerToolType]
+public sealed class PlanningMcpService
+{
+ private readonly TaskRepository _tasks;
+ private readonly PlanningMcpContextAccessor _contextAccessor;
+ private readonly HubBroadcaster _broadcaster;
+
+ public PlanningMcpService(
+ TaskRepository tasks,
+ PlanningMcpContextAccessor contextAccessor,
+ HubBroadcaster broadcaster)
+ {
+ _tasks = tasks;
+ _contextAccessor = contextAccessor;
+ _broadcaster = broadcaster;
+ }
+
+ private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
+ => _broadcaster.TaskUpdated(taskId);
+
+ [McpServerTool, Description("Create a new draft child task under the current planning session's parent task.")]
+ public async Task CreateChildTask(
+ string title,
+ string? description,
+ IReadOnlyList? tags,
+ string? commitType,
+ CancellationToken cancellationToken)
+ {
+ var ctx = _contextAccessor.Current;
+ var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
+ await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
+ return new CreatedChildDto(child.Id, "Draft");
+ }
+
+ [McpServerTool, Description("List all child tasks under the current planning session's parent task.")]
+ public async Task> ListChildTasks(
+ CancellationToken cancellationToken)
+ {
+ var ctx = _contextAccessor.Current;
+ var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
+ var list = new List(children.Count);
+ foreach (var c in children)
+ {
+ var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken);
+ list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList()));
+ }
+ return list;
+ }
+
+ [McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")]
+ public async Task UpdateChildTask(
+ string taskId,
+ string? title,
+ string? description,
+ IReadOnlyList? tags,
+ string? commitType,
+ CancellationToken cancellationToken)
+ {
+ var ctx = _contextAccessor.Current;
+ var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
+ ?? throw new InvalidOperationException($"Task {taskId} not found.");
+ if (child.ParentTaskId != ctx.ParentTaskId)
+ throw new InvalidOperationException("Task is not a child of this planning session.");
+ if (child.Status != TaskStatus.Draft)
+ throw new InvalidOperationException("Cannot modify a finalized task.");
+
+ if (title is not null) child.Title = title;
+ if (description is not null) child.Description = description;
+ if (commitType is not null) child.CommitType = commitType;
+ await _tasks.UpdateAsync(child, cancellationToken);
+
+ var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
+ var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
+ await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
+ return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
+ }
+
+ [McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")]
+ public async Task DeleteChildTask(
+ string taskId,
+ CancellationToken cancellationToken)
+ {
+ var ctx = _contextAccessor.Current;
+ var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
+ ?? throw new InvalidOperationException($"Task {taskId} not found.");
+ if (child.ParentTaskId != ctx.ParentTaskId)
+ throw new InvalidOperationException("Task is not a child of this planning session.");
+ if (child.Status != TaskStatus.Draft)
+ throw new InvalidOperationException("Cannot delete a finalized task.");
+
+ await _tasks.DeleteAsync(taskId, cancellationToken);
+ await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
+ }
+
+ [McpServerTool, Description("Update the title and/or description of the parent planning task itself.")]
+ public async Task UpdatePlanningTask(
+ string? title,
+ string? description,
+ CancellationToken cancellationToken)
+ {
+ var ctx = _contextAccessor.Current;
+ await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken);
+ await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
+ }
+
+ [McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")]
+ public async Task Finalize(
+ bool queueAgentTasks,
+ CancellationToken cancellationToken)
+ {
+ var ctx = _contextAccessor.Current;
+ var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
+ await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
+ return count;
+ }
+}
diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
new file mode 100644
index 0000000..ea1b7fc
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
@@ -0,0 +1,12 @@
+namespace ClaudeDo.Worker.Planning;
+
+public sealed record PlanningSessionStartContext(
+ string ParentTaskId,
+ string WorkingDir,
+ PlanningSessionFiles Files);
+
+public sealed record PlanningSessionResumeContext(
+ string ParentTaskId,
+ string WorkingDir,
+ string ClaudeSessionId,
+ string McpConfigPath);
\ No newline at end of file
diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
new file mode 100644
index 0000000..ffe4390
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
@@ -0,0 +1,7 @@
+namespace ClaudeDo.Worker.Planning;
+
+public sealed record PlanningSessionFiles(
+ string SessionDirectory,
+ string McpConfigPath,
+ string SystemPromptPath,
+ string InitialPromptPath);
\ No newline at end of file
diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
new file mode 100644
index 0000000..84f5991
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
@@ -0,0 +1,186 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using ClaudeDo.Data;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using Microsoft.EntityFrameworkCore;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Planning;
+
+public sealed class PlanningSessionManager
+{
+ private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
+
+ private readonly IDbContextFactory? _factory;
+ private readonly TaskRepository? _tasksOverride;
+ private readonly ListRepository? _listsOverride;
+ private readonly string _rootDirectory;
+
+ // DI constructor — uses factory so this singleton can create scoped repos per call.
+ public PlanningSessionManager(IDbContextFactory factory, string rootDirectory)
+ {
+ _factory = factory;
+ _rootDirectory = rootDirectory;
+ }
+
+ // Test constructor — accepts repos directly (single shared context, test-scoped).
+ public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory)
+ {
+ _tasksOverride = tasks;
+ _listsOverride = lists;
+ _rootDirectory = rootDirectory;
+ }
+
+ private (TaskRepository tasks, ListRepository lists, ClaudeDoDbContext? ctx) CreateRepos()
+ {
+ if (_tasksOverride is not null)
+ return (_tasksOverride, _listsOverride!, null);
+ var ctx = _factory!.CreateDbContext();
+ return (new TaskRepository(ctx), new ListRepository(ctx), ctx);
+ }
+
+ public async Task StartAsync(string taskId, CancellationToken ct)
+ {
+ var (tasks, lists, ctx) = CreateRepos();
+ await using var _ = ctx;
+
+ var task = await tasks.GetByIdAsync(taskId, ct)
+ ?? throw new InvalidOperationException($"Task {taskId} not found.");
+ if (task.ParentTaskId is not null)
+ throw new InvalidOperationException("Cannot start a planning session on a child task.");
+ if (task.Status != TaskStatus.Manual)
+ throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
+
+ var token = GenerateToken();
+ var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
+ ?? throw new InvalidOperationException("Failed to transition task to Planning.");
+
+ var sessionDir = Path.Combine(_rootDirectory, taskId);
+ Directory.CreateDirectory(sessionDir);
+
+ var files = new PlanningSessionFiles(
+ sessionDir,
+ Path.Combine(sessionDir, "mcp.json"),
+ Path.Combine(sessionDir, "system-prompt.md"),
+ Path.Combine(sessionDir, "initial-prompt.txt"));
+
+ await File.WriteAllTextAsync(files.McpConfigPath, BuildMcpConfigJson(token), ct);
+ await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
+ await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
+
+ var list = await lists.GetByIdAsync(task.ListId, ct)
+ ?? throw new InvalidOperationException($"List {task.ListId} not found.");
+ return new PlanningSessionStartContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), files);
+ }
+
+ public async Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
+ {
+ var (tasks, _, ctx) = CreateRepos();
+ await using var __ = ctx;
+ return await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
+ }
+
+ public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct)
+ {
+ var (tasks, _, ctx) = CreateRepos();
+ await using var __ = ctx;
+ var children = await tasks.GetChildrenAsync(taskId, ct);
+ return children.Count(c => c.Status == TaskStatus.Draft);
+ }
+
+ public async Task DiscardAsync(string taskId, CancellationToken ct)
+ {
+ var (tasks, _, ctx) = CreateRepos();
+ await using var __ = ctx;
+
+ var ok = await tasks.DiscardPlanningAsync(taskId, ct);
+ var sessionDir = Path.Combine(_rootDirectory, taskId);
+ if (Directory.Exists(sessionDir))
+ {
+ try { Directory.Delete(sessionDir, recursive: true); }
+ catch { /* best effort */ }
+ }
+ if (!ok)
+ throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
+ }
+
+ public async Task ResumeAsync(string taskId, CancellationToken ct)
+ {
+ var (tasks, lists, ctx) = CreateRepos();
+ await using var _ = ctx;
+
+ var task = await tasks.GetByIdAsync(taskId, ct)
+ ?? throw new InvalidOperationException($"Task {taskId} not found.");
+ if (task.Status != TaskStatus.Planning)
+ throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
+ if (string.IsNullOrEmpty(task.PlanningSessionId))
+ throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
+
+ var sessionDir = Path.Combine(_rootDirectory, taskId);
+ var mcpConfigPath = Path.Combine(sessionDir, "mcp.json");
+ if (!File.Exists(mcpConfigPath))
+ throw new InvalidOperationException($"Session directory missing: {sessionDir}");
+
+ var list = await lists.GetByIdAsync(task.ListId, ct)
+ ?? throw new InvalidOperationException($"List {task.ListId} not found.");
+ return new PlanningSessionResumeContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), task.PlanningSessionId, mcpConfigPath);
+ }
+
+ private static string GenerateToken()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(32);
+ return Convert.ToBase64String(bytes)
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+ }
+
+ private static string BuildMcpConfigJson(string token)
+ {
+ var payload = new
+ {
+ mcpServers = new
+ {
+ claudedo = new
+ {
+ type = "http",
+ url = McpServerUrl,
+ headers = new Dictionary
+ {
+ ["Authorization"] = $"Bearer {token}"
+ }
+ }
+ }
+ };
+ return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
+ }
+
+ private static string BuildSystemPrompt() =>
+ """
+ You are a planning assistant for ClaudeDo.
+ Your role is to help break down a task into smaller, actionable subtasks.
+
+ Use the available MCP tools (mcp__claudedo__*) to create child tasks.
+ When you are done planning, finalize the session.
+
+ Be concise and focused. Each subtask should be independently executable.
+ """;
+
+ private static string BuildInitialPrompt(TaskEntity task)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"# Task: {task.Title}");
+ if (!string.IsNullOrWhiteSpace(task.Description))
+ {
+ sb.AppendLine();
+ sb.AppendLine(task.Description);
+ }
+ sb.AppendLine();
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine("Please analyze this task and break it down into concrete subtasks.");
+ return sb.ToString();
+ }
+}
\ No newline at end of file
diff --git a/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs b/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
new file mode 100644
index 0000000..6cf7043
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
@@ -0,0 +1,40 @@
+using ClaudeDo.Data.Repositories;
+using Microsoft.AspNetCore.Http;
+
+namespace ClaudeDo.Worker.Planning;
+
+public sealed class PlanningTokenAuthMiddleware
+{
+ private readonly RequestDelegate _next;
+
+ public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next;
+
+ public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks)
+ {
+ if (!ctx.Request.Path.StartsWithSegments("/mcp"))
+ {
+ await _next(ctx);
+ return;
+ }
+
+ var auth = ctx.Request.Headers["Authorization"].ToString();
+ if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
+ {
+ ctx.Response.StatusCode = 401;
+ await ctx.Response.WriteAsync("Missing bearer token");
+ return;
+ }
+
+ var token = auth.Substring("Bearer ".Length).Trim();
+ var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
+ if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning)
+ {
+ ctx.Response.StatusCode = 401;
+ await ctx.Response.WriteAsync("Invalid or expired planning token");
+ return;
+ }
+
+ ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
+ await _next(ctx);
+ }
+}
diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
new file mode 100644
index 0000000..cb03995
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
@@ -0,0 +1,125 @@
+// Claude CLI flags (verified 2026-04-23 via Context7):
+// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists)
+// Allowed-tools: --allowedTools (camelCase), comma-separated tokens
+// System prompt: --append-system-prompt-file (file form)
+// Session ID: no pre-assign flag; resume with --resume
+// Launch model: wt.exe directly spawns claude.exe via argv (UseShellExecute=false).
+// No cmd /k shim — arbitrary initial-prompt content would be re-parsed by cmd.exe otherwise.
+
+using System.Diagnostics;
+
+namespace ClaudeDo.Worker.Planning;
+
+public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
+{
+ private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
+ private const string Model = "claude-sonnet-4-6";
+
+ private readonly string _wtPath;
+ private readonly string _claudePath;
+
+ public WindowsTerminalPlanningLauncher(string wtPath, string claudePath)
+ {
+ _wtPath = wtPath;
+ _claudePath = claudePath;
+ }
+
+ public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
+ {
+ if (!Directory.Exists(ctx.WorkingDir))
+ throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
+
+ if (!File.Exists(ctx.Files.McpConfigPath))
+ throw new PlanningLaunchException($"MCP config file not found: {ctx.Files.McpConfigPath}");
+
+ var resolvedWt = Resolve(_wtPath);
+ if (resolvedWt is null)
+ throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
+
+ var resolvedClaude = Resolve(_claudePath);
+ if (resolvedClaude is null)
+ throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = resolvedWt,
+ UseShellExecute = false,
+ CreateNoWindow = false,
+ };
+
+ psi.Environment["MAX_THINKING_TOKENS"] = "20000";
+
+ psi.ArgumentList.Add("-d");
+ psi.ArgumentList.Add(ctx.WorkingDir);
+ psi.ArgumentList.Add(resolvedClaude);
+ psi.ArgumentList.Add("--model");
+ psi.ArgumentList.Add(Model);
+ psi.ArgumentList.Add("--mcp-config");
+ psi.ArgumentList.Add(ctx.Files.McpConfigPath);
+ psi.ArgumentList.Add("--append-system-prompt-file");
+ psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
+ psi.ArgumentList.Add("--allowedTools");
+ psi.ArgumentList.Add(AllowedTools);
+ psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
+
+ var proc = Process.Start(psi)
+ ?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
+
+ return Task.CompletedTask;
+ }
+
+ public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
+ {
+ if (!Directory.Exists(ctx.WorkingDir))
+ throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
+
+ var resolvedWt = Resolve(_wtPath);
+ if (resolvedWt is null)
+ throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
+
+ var resolvedClaude = Resolve(_claudePath);
+ if (resolvedClaude is null)
+ throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = resolvedWt,
+ UseShellExecute = false,
+ CreateNoWindow = false,
+ };
+
+ psi.ArgumentList.Add("-d");
+ psi.ArgumentList.Add(ctx.WorkingDir);
+ psi.ArgumentList.Add(resolvedClaude);
+ psi.ArgumentList.Add("--resume");
+ psi.ArgumentList.Add(ctx.ClaudeSessionId);
+ psi.ArgumentList.Add("--mcp-config");
+ psi.ArgumentList.Add(ctx.McpConfigPath);
+
+ var proc = Process.Start(psi)
+ ?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
+
+ return Task.CompletedTask;
+ }
+
+ private static string? Resolve(string pathOrName)
+ {
+ if (File.Exists(pathOrName))
+ return pathOrName;
+
+ // Try PATH resolution
+ var envPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
+ var extensions = new[] { "", ".exe", ".cmd", ".bat" };
+ foreach (var dir in envPath.Split(Path.PathSeparator))
+ {
+ foreach (var ext in extensions)
+ {
+ var candidate = Path.Combine(dir, pathOrName + ext);
+ if (File.Exists(candidate))
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index 5f4977a..25c9ece 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
+using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Services;
using Microsoft.EntityFrameworkCore;
@@ -51,6 +52,25 @@ builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
builder.Services.AddSingleton();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
+// Planning session services.
+var planningSessionsDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".todo-app", "planning-sessions");
+builder.Services.AddSingleton(sp =>
+ new PlanningSessionManager(
+ sp.GetRequiredService>(),
+ planningSessionsDir));
+builder.Services.AddSingleton();
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
+builder.Services.AddScoped(sp =>
+ sp.GetRequiredService>().CreateDbContext());
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddMcpServer()
+ .WithHttpTransport()
+ .WithTools();
+
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
@@ -75,7 +95,9 @@ catch (Exception ex)
app.Logger.LogWarning(ex, "Default agent seeding failed");
}
+app.UseMiddleware();
app.MapHub("/hub");
+app.MapMcp("/mcp");
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
cfg.SignalRPort, cfg.DbPath);
diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
new file mode 100644
index 0000000..614161e
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
@@ -0,0 +1,221 @@
+using ClaudeDo.Data;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Hub;
+using ClaudeDo.Worker.Planning;
+using ClaudeDo.Worker.Services;
+using ClaudeDo.Worker.Tests.Infrastructure;
+using Microsoft.AspNetCore.SignalR;
+using Xunit;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Tests.Hub;
+
+public sealed class PlanningHubTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly ClaudeDoDbContext _ctx;
+ private readonly TaskRepository _tasks;
+ private readonly ListRepository _lists;
+ private readonly string _rootDir;
+ private readonly PlanningSessionManager _planning;
+ private readonly FakePlanningLauncher _launcher;
+ private readonly RecordingClientProxy _proxy;
+
+ public PlanningHubTests()
+ {
+ _ctx = _db.CreateContext();
+ _tasks = new TaskRepository(_ctx);
+ _lists = new ListRepository(_ctx);
+ _rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}");
+ _planning = new PlanningSessionManager(_tasks, _lists, _rootDir);
+ _launcher = new FakePlanningLauncher();
+ _proxy = new RecordingClientProxy();
+ }
+
+ public void Dispose()
+ {
+ _ctx.Dispose();
+ _db.Dispose();
+ try { Directory.Delete(_rootDir, recursive: true); } catch { }
+ }
+
+ private WorkerHub CreateHub()
+ {
+ var hub = new WorkerHub(
+ null!, null!, null!, null!, null!, null!, null!, null!,
+ _planning, _launcher);
+ hub.Clients = new FakeHubCallerClients(_proxy);
+ hub.Context = new FakeHubCallerContext();
+ return hub;
+ }
+
+ private async Task<(string listId, string taskId)> SeedAsync()
+ {
+ var listId = Guid.NewGuid().ToString();
+ var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(wd);
+ await _lists.AddAsync(new ListEntity
+ {
+ Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow,
+ });
+ var task = new TaskEntity
+ {
+ Id = Guid.NewGuid().ToString(),
+ ListId = listId,
+ Title = "Do something",
+ Status = TaskStatus.Manual,
+ CreatedAt = DateTime.UtcNow,
+ CommitType = "feat",
+ };
+ await _tasks.AddAsync(task);
+ return (listId, task.Id);
+ }
+
+ [Fact]
+ public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
+ {
+ var (_, taskId) = await SeedAsync();
+ var hub = CreateHub();
+
+ var ctx = await hub.StartPlanningSessionAsync(taskId);
+
+ Assert.Equal(taskId, ctx.ParentTaskId);
+ Assert.Equal(1, _launcher.LaunchStartCalls);
+ Assert.Equal(0, _launcher.LaunchResumeCalls);
+
+ var loaded = await _tasks.GetByIdAsync(taskId);
+ Assert.Equal(TaskStatus.Planning, loaded!.Status);
+
+ Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
+ }
+
+ [Fact]
+ public async Task StartPlanningSessionAsync_LauncherFails_Discards()
+ {
+ var (_, taskId) = await SeedAsync();
+ _launcher.ShouldThrow = true;
+ var hub = CreateHub();
+
+ await Assert.ThrowsAsync(() =>
+ hub.StartPlanningSessionAsync(taskId));
+
+ var loaded = await _tasks.GetByIdAsync(taskId);
+ Assert.Equal(TaskStatus.Manual, loaded!.Status);
+
+ var sessionDir = Path.Combine(_rootDir, taskId);
+ Assert.False(Directory.Exists(sessionDir));
+ }
+
+ [Fact]
+ public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts()
+ {
+ var (_, taskId) = await SeedAsync();
+ // Put task into Planning state first
+ await _planning.StartAsync(taskId, CancellationToken.None);
+ _proxy.Sent.Clear();
+
+ var hub = CreateHub();
+ await hub.DiscardPlanningSessionAsync(taskId);
+
+ var loaded = await _tasks.GetByIdAsync(taskId);
+ Assert.Equal(TaskStatus.Manual, loaded!.Status);
+ Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
+ }
+
+ [Fact]
+ public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts()
+ {
+ var (_, taskId) = await SeedAsync();
+ await _planning.StartAsync(taskId, CancellationToken.None);
+ await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
+ await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
+ _proxy.Sent.Clear();
+
+ var hub = CreateHub();
+ var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false);
+
+ Assert.Equal(2, count);
+ Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
+ }
+
+ [Fact]
+ public async Task GetPendingDraftCountAsync_ReturnsCount()
+ {
+ var (_, taskId) = await SeedAsync();
+ await _planning.StartAsync(taskId, CancellationToken.None);
+ await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
+ await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
+
+ var hub = CreateHub();
+ var count = await hub.GetPendingDraftCountAsync(taskId);
+
+ Assert.Equal(2, count);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
+{
+ public bool ShouldThrow { get; set; }
+ public int LaunchStartCalls { get; private set; }
+ public int LaunchResumeCalls { get; private set; }
+
+ public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
+ {
+ if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
+ LaunchStartCalls++;
+ return Task.CompletedTask;
+ }
+
+ public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
+ {
+ LaunchResumeCalls++;
+ return Task.CompletedTask;
+ }
+}
+
+internal sealed class RecordingClientProxy : IClientProxy
+{
+ public List<(string method, object?[] args)> Sent { get; } = new();
+
+ public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
+ {
+ Sent.Add((method, args));
+ return Task.CompletedTask;
+ }
+}
+
+internal sealed class FakeHubCallerClients : IHubCallerClients
+{
+ private readonly IClientProxy _all;
+ public FakeHubCallerClients(IClientProxy proxy) => _all = proxy;
+
+ public IClientProxy All => _all;
+ public IClientProxy Caller => _all;
+ public IClientProxy Others => _all;
+ public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => _all;
+ public IClientProxy Client(string connectionId) => _all;
+ public IClientProxy Clients(IReadOnlyList connectionIds) => _all;
+ public IClientProxy Group(string groupName) => _all;
+ public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => _all;
+ public IClientProxy Groups(IReadOnlyList groupNames) => _all;
+ public IClientProxy OthersInGroup(string groupName) => _all;
+ public IClientProxy User(string userId) => _all;
+ public IClientProxy Users(IReadOnlyList userIds) => _all;
+}
+
+internal sealed class FakeHubCallerContext : HubCallerContext
+{
+ public override string ConnectionId => "test-conn";
+ public override string? UserIdentifier => null;
+ public override System.Security.Claims.ClaimsPrincipal? User => null;
+ public override IDictionary