using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Skills; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Runner; // Builds the launch spec an embedded ConPTY terminal needs to open an interactive Claude // session in an existing task's worktree -- the SAME worktree prep as an autonomous run: // session-skills seeded onto disk (reuses ISessionSkillSeeder + TaskRunner.UnionSkillNames, // exactly like TaskRunner.RunAsync/ContinueAsync) and the same run environment variables // (reuses ClaudeProcess's MCP_TOOL_TIMEOUT). Exe/Args reuse WindowsTerminalLauncher's // --resume argument construction. Guards mirror WorkerHub.ResumeTaskInTerminal, except a // never-run task (no persisted SessionId) is not an error here -- it's a fresh-start spec. // // The run-scoped "claudedo_run" MCP server (AskUser/SuggestImprovement) that TaskRunner // wires per headless run is intentionally NOT reused: it exists so an unattended run can // ask the user a question, which is moot when the user is already driving the session by // hand. The always-on `mcp__claudedo__*` tools remain available via the globally-registered // MCP server (installer's RegisterMcpStep), exactly as they already are for a plain // `--resume` pickup in a Windows Terminal window. public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService { private readonly IDbContextFactory _dbFactory; private readonly ISessionSkillSeeder _skillSeeder; private readonly ISessionSkillRegistry _skillRegistry; private readonly WorktreeManager _wtManager; private readonly string _claudePath; public InteractiveLaunchSpecService( IDbContextFactory dbFactory, ISessionSkillSeeder skillSeeder, ISessionSkillRegistry skillRegistry, WorktreeManager wtManager, WorkerConfig cfg) { _dbFactory = dbFactory; _skillSeeder = skillSeeder; _skillRegistry = skillRegistry; _wtManager = wtManager; _claudePath = cfg.ClaudeBin; } public async Task BuildForTaskAsync(string taskId, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException(); if (task.Status is TaskStatus.Running or TaskStatus.Queued) throw new InvalidOperationException("Can't open an interactive session for a running or queued task -- interrupt it first."); var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); var isFreshWorktree = false; if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept)) { // No usable worktree yet -- if the task's list points at a git repo, create one // on demand via the SAME mechanism an autonomous run uses (WorktreeManager.CreateAsync: // branch naming, base commit resolution, worktree-root strategy, DB registration). var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct); if (list?.WorkingDir is null) throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree."); await _wtManager.CreateAsync(task, list, ct); worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct) ?? throw new InvalidOperationException("Worktree creation did not persist a worktree row."); isFreshWorktree = true; } else if (!Directory.Exists(worktree.Path)) { throw new InvalidOperationException("The task's worktree directory no longer exists."); } var listConfig = await new ListRepository(ctx).GetConfigAsync(task.ListId, ct); var globalSettings = await new AppSettingsRepository(ctx).GetAsync(ct); // A brand-new worktree has no prior session to resume, regardless of any session // history the task accumulated before its previous worktree went away. var run = isFreshWorktree ? null : await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct); var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills); var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct); await _skillSeeder.SeedAsync(worktree.Path, skillNames, isWorktree: true, ct); var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); // Resume an existing session as-is; for a fresh session, seed the interactive TUI with // the task's prompt (title + description) as claude's positional prompt so it starts on // the task -- the user then supervises/answers rather than retyping it. var args = run?.SessionId is { Length: > 0 } sessionId ? WindowsTerminalLauncher.BuildResumeArgs(sessionId) : BuildFreshPromptArgs(task); // Start the hand-driven session at the effort configured for the model this task would run // under, instead of inheriting whatever the user's global Claude Code config happens to be. // The model itself is deliberately NOT forced here — the user can still switch it in the TUI. args = WithEffort(args, EffortFor(globalSettings, task.Model ?? listConfig?.Model)); // Same run environment variable ClaudeProcess sets for every headless run: the // AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for // every other tool. var env = new Dictionary { ["MCP_TOOL_TIMEOUT"] = "200000", }; return new LaunchSpec(worktree.Path, resolvedClaude, args, env); } public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx) { var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); // Mirrors the env the wt planning launcher set (MAX_THINKING_TOKENS + the per-session // planning token); MCP_TOOL_TIMEOUT matches the other embedded-ConPTY specs. Applied to // the UI process env at spawn time (see PtyTerminalSession) — process-global by design. var env = new Dictionary { ["MAX_THINKING_TOKENS"] = "20000", ["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token, ["MCP_TOOL_TIMEOUT"] = "200000", }; return new LaunchSpec( ctx.WorkingDir, resolvedClaude, WithEffort(WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)), env); } public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx) { var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); var env = new Dictionary { ["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token, ["MCP_TOOL_TIMEOUT"] = "200000", }; return new LaunchSpec( ctx.WorkingDir, resolvedClaude, WithEffort(WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)), env); } public async Task BuildForDirectoryAsync(string directory, CancellationToken ct) { if (!Directory.Exists(directory)) throw new InvalidOperationException($"Directory does not exist: {directory}"); var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var settings = await new AppSettingsRepository(ctx).GetAsync(ct); var env = new Dictionary { ["MCP_TOOL_TIMEOUT"] = "200000", }; // No task and no list here — the global default model's preset decides the effort. return new LaunchSpec( directory, resolvedClaude, WithEffort(Array.Empty(), EffortFor(settings, settings.DefaultModel)), env); } // Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll, // diff, review/merge, continue/abort merge), read/search, Edit + Bash for hand-resolving // conflict markers the MCP tools left behind, and web/skill lookups. private const string MergeHelperAllowedTools = "mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill"; public async Task BuildForMergeHelperAsync(IReadOnlyList taskIds, string listId, CancellationToken ct) { if (taskIds.Count == 0) throw new InvalidOperationException("No tasks selected for the list handler."); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var taskRepo = new TaskRepository(ctx); var listRepo = new ListRepository(ctx); var list = await listRepo.GetByIdAsync(listId, ct) ?? throw new KeyNotFoundException($"List not found: {listId}"); var repoDir = list.WorkingDir; if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir)) throw new InvalidOperationException($"list '{list.Name}' has no existing working directory"); var briefLines = new List(); foreach (var id in taskIds) { var task = await taskRepo.GetByIdAsync(id, ct) ?? throw new KeyNotFoundException($"Task not found: {id}"); briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})"); } var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString()); Directory.CreateDirectory(sessionDir); var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md"); await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct); var briefPath = Path.Combine(sessionDir, "brief.md"); await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial, new Dictionary { ["scope"] = $"List: {list.Name}", ["repo"] = repoDir, ["tasks"] = string.Join("\n", briefLines), }), ct); var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); // Mirrors WindowsTerminalLauncher.BuildPlanningStartArgs ordering: variadic flags // (--allowedTools, --add-dir) first, then a single-value flag, then the single-line // positional kickoff LAST — a multi-line positional prompt truncates at the first // newline, so the full multi-line brief travels via the file exposed through --add-dir. var listConfig = await listRepo.GetConfigAsync(listId, ct); var settings = await new AppSettingsRepository(ctx).GetAsync(ct); var args = new List { "--effort", EffortFor(settings, listConfig?.Model), "--permission-mode", "auto", "--allowedTools", MergeHelperAllowedTools, "--add-dir", sessionDir, repoDir, "--append-system-prompt-file", systemPromptPath, $"Read the file {briefPath} first. It lists the tasks you must handle and their status. " + "After reading it, begin the session as your instructions describe.", }; var env = new Dictionary { ["MCP_TOOL_TIMEOUT"] = "200000", }; return new LaunchSpec(repoDir, resolvedClaude, args, env); } // The reasoning effort configured for a model in Settings → General. Falls back to the shipped // preset for that model, so a missing/malformed settings row can never block a launch. private static string EffortFor(AppSettingsEntity settings, string? model) => ModelPresets.For(settings.ModelPresets, model ?? settings.DefaultModel).Effort; // Prepends `--effort `. It has to lead: a positional kickoff prompt must stay last, and // it may only follow a single-value flag — a variadic flag would swallow it. private static IReadOnlyList WithEffort(IReadOnlyList args, string effort) { var result = new List(args.Count + 2) { "--effort", effort }; result.AddRange(args); return result; } private AppSettingsEntity ReadSettings() { using var ctx = _dbFactory.CreateDbContext(); return new AppSettingsRepository(ctx).GetAsync().GetAwaiter().GetResult(); } // The positional prompt claude opens the interactive session on. Empty (no positional arg) // if the task has neither a title nor a description. private static IReadOnlyList BuildFreshPromptArgs(TaskEntity task) { var title = task.Title?.Trim(); var description = task.Description?.Trim(); var prompt = (string.IsNullOrEmpty(title), string.IsNullOrEmpty(description)) switch { (false, false) => $"{title}\n\n{description}", (false, true) => title!, (true, false) => description!, _ => string.Empty, }; return string.IsNullOrEmpty(prompt) ? Array.Empty() : new[] { prompt }; } private async Task> FilterToInstalledSkillsAsync(IReadOnlyList requested, CancellationToken ct) { if (requested.Count == 0) return requested; var installed = (await _skillRegistry.ListAsync(ct)) .Select(s => s.Name) .ToHashSet(StringComparer.Ordinal); return requested.Where(installed.Contains).ToList(); } }