using System.Globalization; using System.Reflection; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Agents; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Logging; using ClaudeDo.Worker.Online; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Refine; using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report.Interfaces; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.State; using ClaudeDo.Worker.Worktrees; using System.Text.Json; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Hub; public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt); public record AppSettingsDto( string DefaultClaudeInstructions, string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode, int MaxParallelExecutions, string WorktreeStrategy, string? CentralWorktreeRoot, bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays, string? ReportExcludedPaths, int StandupWeekday, int DailyPrepMaxTasks, List? SessionSkills = null, List? ModelPresets = null); // Per-model run defaults (effort + turn budget) edited in Settings -> General. public record ModelPresetDto(string Model, string Effort, int MaxTurns); public record SessionSkillDto( string Name, string Description, string SourceUrl, string PinnedRef, DateTimeOffset AddedAt); public record WorktreeCleanupDto(int Removed); public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public record WorktreeOverviewDto( string TaskId, string TaskTitle, ClaudeDo.Data.Models.TaskStatus TaskStatus, string ListId, string ListName, string Path, string BranchName, string BaseCommit, WorktreeState State, string? DiffStat, DateTime CreatedAt, bool PathExistsOnDisk); public record ForceRemoveResultDto(bool Removed, string? Reason); public record PendingQuestionDto(string TaskId, string QuestionId, string Question); public record MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage); public record MergePreviewDto(string Status, IReadOnlyList ConflictFiles, int ChangedFileCount); public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalBranches); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList Segments); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false); public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); public record SeedResultDto(int Copied, int Skipped); public record OnlineInboxStateDto( bool Enabled, string ApiBaseUrl, string Authority, string ClientId, string Scopes, string RedirectUri, bool SignedIn, int PollIntervalSeconds); public record OnlineInboxConfigInput( bool Enabled, string ApiBaseUrl, int PollIntervalSeconds, string Authority, string ClientId, string Scopes, string RedirectUri); public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub { private static readonly string Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; private readonly QueueService _queue; private readonly IQueueWaker _waker; private readonly AgentFileService _agentService; private readonly DefaultAgentSeeder _seeder; private readonly HubBroadcaster _broadcaster; private readonly IDbContextFactory _dbFactory; private readonly WorktreeMaintenanceService _wtMaintenance; private readonly TaskResetService _resetService; private readonly TaskMergeService _mergeService; private readonly PlanningSessionManager _planning; private readonly ITerminalLauncher _launcher; private readonly PlanningAggregator _planningAggregator; private readonly PlanningMergeOrchestrator _planningMergeOrchestrator; private readonly PlanningChainCoordinator _planningChain; private readonly IPrimeScheduleSignal _primeSignal; private readonly IPrimeRunner _primeRunner; private readonly ITaskStateService _state; private readonly IWeekReportService _report; private readonly IRefineRunner _refineRunner; private readonly WorkerConfig _cfg; private readonly OnlineInboxConfig _onlineInboxConfig; private readonly OnlineTokenStore _onlineTokenStore; private readonly Runner.PendingQuestionRegistry _pendingQuestions; private readonly LogRingBuffer? _logBuffer; private readonly ISessionSkillRegistry _skillRegistry; private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec; private readonly WorktreeManager? _worktreeManager; public WorkerHub( QueueService queue, IQueueWaker waker, AgentFileService agentService, DefaultAgentSeeder seeder, HubBroadcaster broadcaster, IDbContextFactory dbFactory, WorktreeMaintenanceService wtMaintenance, TaskResetService resetService, TaskMergeService mergeService, PlanningSessionManager planning, ITerminalLauncher launcher, PlanningAggregator planningAggregator, PlanningMergeOrchestrator planningMergeOrchestrator, PlanningChainCoordinator planningChain, IPrimeScheduleSignal primeSignal, IPrimeRunner primeRunner, ITaskStateService state, IWeekReportService report, IRefineRunner refineRunner, WorkerConfig cfg, OnlineInboxConfig onlineInboxConfig, OnlineTokenStore onlineTokenStore, Runner.PendingQuestionRegistry pendingQuestions, ISessionSkillRegistry skillRegistry, LogRingBuffer? logBuffer = null, IInteractiveLaunchSpecService? interactiveLaunchSpec = null, WorktreeManager? worktreeManager = null) { _queue = queue; _waker = waker; _agentService = agentService; _seeder = seeder; _broadcaster = broadcaster; _dbFactory = dbFactory; _wtMaintenance = wtMaintenance; _resetService = resetService; _mergeService = mergeService; _planning = planning; _launcher = launcher; _planningAggregator = planningAggregator; _planningMergeOrchestrator = planningMergeOrchestrator; _planningChain = planningChain; _primeSignal = primeSignal; _primeRunner = primeRunner; _state = state; _report = report; _refineRunner = refineRunner; _cfg = cfg; _onlineInboxConfig = onlineInboxConfig; _onlineTokenStore = onlineTokenStore; _pendingQuestions = pendingQuestions; _skillRegistry = skillRegistry; _logBuffer = logBuffer; _interactiveLaunchSpec = interactiveLaunchSpec; _worktreeManager = worktreeManager; } // Persistence boundary for the session_skills JSON-array columns (task/list/global). // A null/empty selection persists as null so "inherit / none" stays clean, and the // shape matches TaskRunner.UnionSkillNames's expected JSON string array. private static string? SkillsToJson(List? names) => names is null or { Count: 0 } ? null : JsonSerializer.Serialize(names); private static List? SkillsFromJson(string? json) { if (string.IsNullOrWhiteSpace(json)) return null; try { return JsonSerializer.Deserialize>(json); } catch (JsonException) { return null; } } /// Deliver the user's answer to a question a running task raised via AskUser. /// Returns false if no matching question is still pending (already answered or timed out). public bool AnswerTaskQuestion(string taskId, string questionId, string answer) => _pendingQuestions.TryAnswer(taskId, questionId, answer ?? string.Empty); /// The question a running task is currently blocked on, if any (for UI re-attach). public PendingQuestionDto? GetPendingQuestion(string taskId) { var q = _pendingQuestions.Get(taskId); return q is null ? null : new PendingQuestionDto(q.TaskId, q.QuestionId, q.Question); } /// Recent worker log records (last 30 min, all levels) for the Log Visualizer overlay. public IReadOnlyList GetRecentLogs() => _logBuffer?.Snapshot() ?? Array.Empty(); // Maps the two exceptions service methods throw into client-facing HubExceptions: // KeyNotFoundException -> notFoundMessage, InvalidOperationException -> its own message. private static async Task HubGuard(Func action, string notFoundMessage = "task not found") { try { await action(); } catch (KeyNotFoundException) { throw new HubException(notFoundMessage); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } } private static async Task HubGuard(Func> action, string notFoundMessage = "task not found") { try { return await action(); } catch (KeyNotFoundException) { throw new HubException(notFoundMessage); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } } public async Task QueuePlanningSubtasksAsync(string parentTaskId) { try { await _planningChain.QueuePlanAsync(parentTaskId, Context.ConnectionAborted); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } await using var ctx = await _dbFactory.CreateDbContextAsync(); var childIds = await ctx.Tasks .Where(t => t.ParentTaskId == parentTaskId) .Select(t => t.Id) .ToListAsync(); await _broadcaster.TaskUpdated(parentTaskId); foreach (var id in childIds) await _broadcaster.TaskUpdated(id); } public string Ping() => $"pong v{Version}"; public IReadOnlyList GetActive() { return _queue.GetActive() .Select(a => new ActiveTaskDto(a.slot, a.taskId, a.startedAt)) .ToList(); } public async Task RunNow(string taskId) { try { await _queue.RunNow(taskId); } catch (InvalidOperationException) { throw new HubException("override slot busy"); } catch (KeyNotFoundException) { throw new HubException("task not found"); } } public Task ContinueTask(string taskId, string followUpPrompt) => HubGuard(() => _queue.ContinueTask(taskId, followUpPrompt)); public Task ResetTask(string taskId) => HubGuard(() => _resetService.ResetAsync(taskId, CancellationToken.None)); public bool CancelTask(string taskId) => _queue.CancelTask(taskId); public void WakeQueue() => _waker.Wake(); public async Task> GetAgents() => await _agentService.ScanAsync(); public async Task RefreshAgents() => await _agentService.ScanAsync(); public async Task RestoreDefaultAgents() { var result = await _seeder.SeedMissingAsync(); return new SeedResultDto(result.Copied, result.Skipped); } public async Task GetAppSettings() { using var ctx = _dbFactory.CreateDbContext(); var row = await new AppSettingsRepository(ctx).GetAsync(); return new AppSettingsDto( row.DefaultClaudeInstructions, row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode, row.MaxParallelExecutions, row.WorktreeStrategy, row.CentralWorktreeRoot, row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays, row.ReportExcludedPaths, row.StandupWeekday, row.DailyPrepMaxTasks, SkillsFromJson(row.SessionSkills), Data.Models.ModelPresets.Parse(row.ModelPresets) .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList()); } public async Task UpdateAppSettings(AppSettingsDto dto) { using var ctx = _dbFactory.CreateDbContext(); var repo = new AppSettingsRepository(ctx); await repo.UpdateAsync(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId, DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "", DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias, DefaultMaxTurns = dto.DefaultMaxTurns, DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode, MaxParallelExecutions = dto.MaxParallelExecutions, WorktreeStrategy = dto.WorktreeStrategy ?? "sibling", CentralWorktreeRoot = dto.CentralWorktreeRoot, WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled, WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays, ReportExcludedPaths = dto.ReportExcludedPaths, StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday, DailyPrepMaxTasks = dto.DailyPrepMaxTasks, SessionSkills = SkillsToJson(dto.SessionSkills), // Normalized on the way in (unknown models dropped, effort validated, turns clamped). ModelPresets = dto.ModelPresets is { Count: > 0 } ? Data.Models.ModelPresets.Serialize( dto.ModelPresets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns))) : Data.Models.ModelPresets.SerializeDefaults(), }); } public async Task> GetSessionSkills() { var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted); return rows.Select(r => new SessionSkillDto(r.Name, r.Description, r.SourceUrl, r.PinnedRef, r.AddedAt)).ToList(); } public Task> InstallSessionSkill(string url) => HubGuard(async () => { var installed = await _skillRegistry.InstallAsync(url, Context.ConnectionAborted); return installed.ToList(); }); public Task UpdateSessionSkill(string sourceUrl) => HubGuard( () => _skillRegistry.UpdateAsync(sourceUrl, Context.ConnectionAborted)); public Task RemoveSessionSkill(string sourceUrl) => HubGuard( () => _skillRegistry.RemoveAsync(sourceUrl, Context.ConnectionAborted)); public async Task CleanupFinishedWorktrees(string? listId = null) { var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted); foreach (var id in result.RemovedTaskIds) await _broadcaster.WorktreeUpdated(id); return new WorktreeCleanupDto(result.Removed); } public async Task ResetAllWorktrees() { var result = await _wtMaintenance.ResetAllAsync(); foreach (var id in result.RemovedTaskIds) await _broadcaster.WorktreeUpdated(id); return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks); } public async Task> GetWorktreesOverview(string? listId) { var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted); return rows.Select(r => new WorktreeOverviewDto( r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName, r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList(); } public async Task SetWorktreeState(string taskId, WorktreeState newState) { using var ctx = _dbFactory.CreateDbContext(); var repo = new WorktreeRepository(ctx); var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted); if (existing is null) throw new HubException("worktree not found"); // Allowed transitions: Active -> Merged | Discarded | Kept. Terminal states are final. if (existing.State == newState) return true; if (existing.State != WorktreeState.Active || newState == WorktreeState.Active) throw new HubException($"invalid worktree state transition {existing.State} -> {newState}"); await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted); await _broadcaster.WorktreeUpdated(taskId); return true; } public async Task ForceRemoveWorktree(string taskId) { var result = await _wtMaintenance.ForceRemoveAsync(taskId, Context.ConnectionAborted); if (result.Removed) await _broadcaster.WorktreeUpdated(taskId); return new ForceRemoveResultDto(result.Removed, result.Reason); } public Task MergeTask( string taskId, string targetBranch, bool removeWorktree, string commitMessage) => HubGuard(async () => { var r = await _mergeService.MergeAsync( taskId, targetBranch ?? "", removeWorktree, string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage, CancellationToken.None); return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); }); public Task GetMergeTargets(string taskId) => HubGuard(async () => { var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None); return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches); }); public Task PreviewMerge(string taskId, string targetBranch) => HubGuard(async () => { var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None); return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount); }); public Task StartConflictMerge(string taskId, string targetBranch) => HubGuard(async () => { var r = await _mergeService.MergeAsync( taskId, targetBranch ?? "", removeWorktree: false, "Merge task", leaveConflictsInTree: true, CancellationToken.None); if (r.Status == TaskMergeService.StatusBlocked) throw new HubException(r.ErrorMessage ?? "merge blocked"); return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); }); public Task GetMergeConflictDocuments(string taskId) => HubGuard(async () => { var c = await _mergeService.GetConflictDocumentsAsync(taskId, CancellationToken.None); return new MergeConflictDocumentsDto( c.TaskId, c.Files.Select(f => new ConflictDocumentDto( f.Path, f.IsBinary, f.Segments.Select(s => new MergeSegmentDto( s.IsConflict, s.Text, s.Ours, s.Base, s.Theirs)).ToList())).ToList()); }); public Task WriteConflictResolution(string taskId, string path, string resolvedContent) => HubGuard(() => _mergeService.WriteResolutionAsync( taskId, path, resolvedContent ?? "", CancellationToken.None)); public Task ContinueConflictMerge(string taskId) => HubGuard(async () => { var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None); if (r.Status == TaskMergeService.StatusBlocked) throw new HubException(r.ErrorMessage ?? "continue failed"); return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); }); public Task AbortConflictMerge(string taskId) => HubGuard(async () => { var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None); if (r.Status == TaskMergeService.StatusBlocked) throw new HubException(r.ErrorMessage ?? "abort failed"); }); public async Task UpdateList(UpdateListDto dto) { using var ctx = _dbFactory.CreateDbContext(); var repo = new ListRepository(ctx); var entity = await repo.GetByIdAsync(dto.Id); if (entity is null) throw new HubException("list not found"); entity.Name = dto.Name; entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir; entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType; entity.IsManual = dto.IsManual; await repo.UpdateAsync(entity); await _broadcaster.ListUpdated(dto.Id); } public async Task UpdateListConfig(UpdateListConfigDto dto) { using var ctx = _dbFactory.CreateDbContext(); var repo = new ListRepository(ctx); var model = dto.Model.NullIfBlank(); var systemPrompt = dto.SystemPrompt.NullIfBlank(); var agentPath = dto.AgentPath.NullIfBlank(); var sessionSkills = SkillsToJson(dto.SessionSkills); if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null) { await repo.DeleteConfigAsync(dto.ListId); } else { await repo.SetConfigAsync(new ListConfigEntity { ListId = dto.ListId, Model = model, SystemPrompt = systemPrompt, AgentPath = agentPath, MaxTurns = dto.MaxTurns, SessionSkills = sessionSkills, }); } await _broadcaster.ListUpdated(dto.ListId); } public async Task GetListConfig(string listId) { using var ctx = _dbFactory.CreateDbContext(); var repo = new ListRepository(ctx); var config = await repo.GetConfigAsync(listId); if (config is null) return null; return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills)); } public async Task SetTaskStatus(string taskId, string status) { if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) throw new HubException($"unknown status: {status}"); // Queueing goes through the gated transition so draft subtasks can't be queued; // other statuses keep the unconditional "set status freely" affordance. var result = parsed == TaskStatus.Queued ? await _state.EnqueueAsync(taskId, Context.ConnectionAborted) : await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted); if (!result.Ok) throw new HubException(result.Reason ?? "set status failed"); } public Task ApproveReview(string taskId, string targetBranch) => HubGuard(async () => { bool hasChildren; await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None)) hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, CancellationToken.None); if (hasChildren) { await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None); return new MergeResultDto(TaskMergeService.StatusMerged, Array.Empty(), null); } var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None); if (r.Status == TaskMergeService.StatusBlocked) throw new HubException(r.ErrorMessage ?? "approve failed"); return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); }); public async Task RejectReviewToQueue(string taskId, string feedback) { var result = await _state.RejectToQueueAsync(taskId, feedback, Context.ConnectionAborted); if (!result.Ok) throw new HubException(result.Reason ?? "reject failed"); } public async Task RejectReviewToIdle(string taskId) { var result = await _state.RejectToIdleAsync(taskId, Context.ConnectionAborted); if (!result.Ok) throw new HubException(result.Reason ?? "park failed"); } public async Task CancelReview(string taskId) { var result = await _state.CancelAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted); if (!result.Ok) throw new HubException(result.Reason ?? "cancel failed"); } public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto) { using var ctx = _dbFactory.CreateDbContext(); var repo = new TaskRepository(ctx); await repo.UpdateAgentSettingsAsync( dto.TaskId, dto.Model.NullIfBlank(), dto.SystemPrompt.NullIfBlank(), dto.AgentPath.NullIfBlank(), dto.MaxTurns, SkillsToJson(dto.SessionSkills)); await _broadcaster.TaskUpdated(dto.TaskId); } public async Task StartPlanningSessionAsync(string taskId) { var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted); try { await _launcher.LaunchPlanningStartAsync(ctx, Context.ConnectionAborted); } catch (TerminalLaunchException) { // Launch failed before any children could be created; force-cleanup is safe. await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, 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.LaunchPlanningResumeAsync(ctx, Context.ConnectionAborted); return ctx; } // Builds the launch spec an embedded ConPTY terminal (UI process) needs to open an // interactive Claude session in a task's worktree -- same worktree prep as an // autonomous run (session-skills seeding, run env vars), --resume if the task has a // persisted session or a fresh-start spec otherwise. Guards: no running/queued task, // and (once a worktree exists) it must be live on disk. public Task GetInteractiveLaunchSpec(string taskId) => HubGuard(() => { if (_interactiveLaunchSpec is null) throw new InvalidOperationException("Interactive launch spec service is not configured."); return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted); }); // Builds the launch spec for an ad-hoc interactive session in an arbitrary directory -- // no task, no worktree, no session-skills seeding. public Task GetAdHocLaunchSpec(string directory) => HubGuard(() => { if (_interactiveLaunchSpec is null) throw new InvalidOperationException("Interactive launch spec service is not configured."); return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted); }); // Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given // tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label // and cwd to that list. public Task GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() => { if (_interactiveLaunchSpec is null) throw new InvalidOperationException("Interactive launch spec service is not configured."); return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, Context.ConnectionAborted); }); // Starts a planning session (worktree + prompt files + token, task -> Planning) and returns // the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for // StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started // session is discarded (no children exist yet), mirroring StartPlanningSessionAsync. public Task GetPlanningStartLaunchSpec(string taskId) => HubGuard(async () => { if (_interactiveLaunchSpec is null) throw new InvalidOperationException("Interactive launch spec service is not configured."); var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted); try { var spec = _interactiveLaunchSpec.BuildPlanningStart(ctx); await Clients.All.SendAsync("TaskUpdated", taskId); return spec; } catch { await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted); throw; } }); // Resumes a planning session and returns the ConPTY launch spec (--permission-mode plan // --resume). ConPTY replacement for ResumePlanningSessionAsync's external wt window. public Task GetPlanningResumeLaunchSpec(string taskId) => HubGuard(async () => { if (_interactiveLaunchSpec is null) throw new InvalidOperationException("Interactive launch spec service is not configured."); var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted); return _interactiveLaunchSpec.BuildPlanningResume(ctx); }); // Submits an interactively-worked task for review: commits whatever the ConPTY session left // in the worktree (so there is a diff to merge), then transitions Idle/Failed -> WaitingForReview. // The normal Approve flow then merges it. This is the only path that flips a hand-driven session // into the review pipeline — a ConPTY session never touches task status on its own. public Task SubmitTaskForReview(string taskId) => HubGuard(async () => { if (_worktreeManager is null) throw new InvalidOperationException("Worktree manager is not configured."); await using var ctx = await _dbFactory.CreateDbContextAsync(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted) ?? throw new KeyNotFoundException(); if (task.Status is TaskStatus.Running or TaskStatus.Queued) throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first."); if (task.Status is TaskStatus.WaitingForReview or TaskStatus.WaitingForChildren) throw new InvalidOperationException("Task is already awaiting review."); var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted); if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept)) throw new InvalidOperationException("This task has no active worktree to submit."); if (!Directory.Exists(worktree.Path)) throw new InvalidOperationException("The task's worktree directory no longer exists."); var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted) ?? throw new InvalidOperationException("Task list not found."); var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit); await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, Context.ConnectionAborted); var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted); if (!result.Ok) throw new InvalidOperationException(result.Reason ?? "Could not submit for review."); }); public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false) { var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted); if (outcome.Result == DiscardPlanningResult.Discarded) await Clients.All.SendAsync("TaskUpdated", taskId); return outcome; } 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); public Task> GetPlanningAggregate(string planningTaskId) => HubGuard>(async () => { var diffs = await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None); return diffs.Select(d => new SubtaskDiffDto( d.SubtaskId, d.Title, d.BranchName, d.BaseCommit, d.HeadCommit, d.DiffStat, d.UnifiedDiff)).ToList(); }, "planning task not found"); public Task BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch) => HubGuard(async () => { var result = await _planningAggregator.BuildIntegrationBranchAsync( planningTaskId, targetBranch ?? "", CancellationToken.None); return result switch { CombinedDiffResult.Ok ok => new CombinedDiffResultDto( true, ok.Value.IntegrationBranch, ok.Value.UnifiedDiff, null, null), CombinedDiffResult.Failed f => new CombinedDiffResultDto( false, null, null, f.Value.FirstConflictSubtaskId, f.Value.ConflictedFiles), _ => throw new InvalidOperationException("unknown result type"), }; }, "planning task not found"); public async Task ContinuePlanningMerge(string planningTaskId) { try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } } public async Task AbortPlanningMerge(string planningTaskId) { try { await _planningMergeOrchestrator.AbortAsync(planningTaskId, CancellationToken.None); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } } public async Task> ListPrimeSchedules() { using var ctx = _dbFactory.CreateDbContext(); var rows = await new PrimeScheduleRepository(ctx).ListAsync(); return rows.Select(e => new PrimeScheduleDto( e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList(); } public async Task UpsertPrimeSchedule(PrimeScheduleDto dto) { using var ctx = _dbFactory.CreateDbContext(); var repo = new PrimeScheduleRepository(ctx); var existing = await repo.GetAsync(dto.Id); var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity { Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id, Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days, TimeOfDay = dto.TimeOfDay, Enabled = dto.Enabled, PromptOverride = dto.PromptOverride, CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow, LastRunAt = existing?.LastRunAt, }; await repo.UpsertAsync(entity); _primeSignal.Signal(); return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay, entity.Enabled, entity.LastRunAt, entity.PromptOverride); } public async Task DeletePrimeSchedule(Guid id) { using var ctx = _dbFactory.CreateDbContext(); await new PrimeScheduleRepository(ctx).DeleteAsync(id); _primeSignal.Signal(); } public Task RefineTask(string taskId) { _ = _refineRunner.RefineAsync(taskId, CancellationToken.None); return Task.CompletedTask; } public async Task RunDailyPrepNow() { var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null); var firedAt = DateTimeOffset.Now; var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted); await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt); return outcome.Success; } private static DateOnly Day(string iso) => DateOnly.ParseExact(iso, "yyyy-MM-dd", CultureInfo.InvariantCulture); public Task GetWeekReport(string startIso, string endIso) => _report.GetStoredAsync(Day(startIso), Day(endIso)); public Task GenerateWeekReport(string startIso, string endIso) => HubGuard(() => _report.GenerateAsync(Day(startIso), Day(endIso)), "report generation failed"); public async Task> GetDailyNotes(string dayIso) { using var ctx = _dbFactory.CreateDbContext(); var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso)); return notes.Select(n => new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder)).ToList(); } public async Task AddDailyNote(string dayIso, string text) { using var ctx = _dbFactory.CreateDbContext(); var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text); return new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder); } public async Task UpdateDailyNote(string id, string text) { using var ctx = _dbFactory.CreateDbContext(); await new DailyNoteRepository(ctx).UpdateAsync(id, text); } public async Task DeleteDailyNote(string id) { using var ctx = _dbFactory.CreateDbContext(); await new DailyNoteRepository(ctx).DeleteAsync(id); } public Task GetLastPrepLog() { var path = DailyPrepPrompt.LogPath(); if (!File.Exists(path)) return Task.FromResult(string.Empty); const int maxBytes = 256 * 1024; var bytes = File.ReadAllBytes(path); var text = bytes.Length <= maxBytes ? System.Text.Encoding.UTF8.GetString(bytes) : System.Text.Encoding.UTF8.GetString(bytes, bytes.Length - maxBytes, maxBytes); return Task.FromResult(text); } public async Task ClearMyDay() { await using var ctx = await _dbFactory.CreateDbContextAsync(); var ids = await ctx.Tasks.Where(t => t.IsMyDay).Select(t => t.Id).ToListAsync(); if (ids.Count == 0) return 0; await ctx.Tasks.Where(t => t.IsMyDay) .ExecuteUpdateAsync(s => s.SetProperty(t => t.IsMyDay, false)); foreach (var id in ids) await _broadcaster.TaskUpdated(id); return ids.Count; } #pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here. public OnlineInboxStateDto GetOnlineInboxState() { var signedIn = _onlineTokenStore.Read() is not null; return new OnlineInboxStateDto( _onlineInboxConfig.Enabled, _onlineInboxConfig.ApiBaseUrl, _onlineInboxConfig.Zitadel.Authority, _onlineInboxConfig.Zitadel.ClientId, _onlineInboxConfig.Zitadel.Scopes, _onlineInboxConfig.RedirectUri, signedIn, _onlineInboxConfig.PollIntervalSeconds); } public void SetOnlineInboxConfig(OnlineInboxConfigInput input) { _onlineInboxConfig.Enabled = input.Enabled; _onlineInboxConfig.ApiBaseUrl = input.ApiBaseUrl ?? ""; _onlineInboxConfig.PollIntervalSeconds = input.PollIntervalSeconds; _onlineInboxConfig.RedirectUri = input.RedirectUri ?? "http://localhost:8765/callback"; _onlineInboxConfig.Zitadel.Authority = input.Authority ?? ""; _onlineInboxConfig.Zitadel.ClientId = input.ClientId ?? ""; _onlineInboxConfig.Zitadel.Scopes = input.Scopes ?? "openid offline_access"; _cfg.SaveOnlineInbox(); } public void SetOnlineInboxAuth(string refreshToken) { _onlineTokenStore.Save(refreshToken); } public void ClearOnlineInboxAuth() { _onlineTokenStore.Clear(); } #pragma warning restore CA1416 }