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}"); var args = run?.SessionId is { Length: > 0 } sessionId ? WindowsTerminalLauncher.BuildResumeArgs(sessionId) : Array.Empty(); // 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 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}"); var env = new Dictionary { ["MCP_TOOL_TIMEOUT"] = "200000", }; return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty(), env)); } 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(); } }