using System.Diagnostics; using ClaudeDo.Data; using ClaudeDo.Data.Git; 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 for a task -- the SAME worktree prep as an autonomous run when the task's list // points at a git repo: session-skills seeded onto disk (reuses ISessionSkillSeeder + // TaskRunner.UnionSkillNames, exactly like TaskRunner.RunAsync/ContinueAsync) and the same // run environment variables (matches ClaudeProcess's MCP_TOOL_TIMEOUT). When the list's // WorkingDir is not a git repo, no worktree is created at all -- the session opens directly // in that directory (session-skills seeded there too, but isWorktree: false so no // .git/info/exclude write is attempted). Exe/Args reuse WindowsTerminalLauncher's --resume // argument construction. Guards: no running/queued task, and (once a worktree exists) it // must be live on disk -- but 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 { // Claude Code caps HTTP MCP tool calls at 60s unless raised; every ConPTY spec built by this // service lifts it well past wait_for_task_change's 900s server-side cap. Keep in sync with // ClaudeProcess's own MCP_TOOL_TIMEOUT (same value, set independently for headless runs). public const string McpToolTimeoutMs = "930000"; private readonly IDbContextFactory _dbFactory; private readonly ISessionSkillSeeder _skillSeeder; private readonly ISessionSkillRegistry _skillRegistry; private readonly WorktreeManager _wtManager; private readonly GitService _git; private readonly string _claudePath; public InteractiveLaunchSpecService( IDbContextFactory dbFactory, ISessionSkillSeeder skillSeeder, ISessionSkillRegistry skillRegistry, WorktreeManager wtManager, GitService git, WorkerConfig cfg) { _dbFactory = dbFactory; _skillSeeder = skillSeeder; _skillRegistry = skillRegistry; _wtManager = wtManager; _git = git; _claudePath = cfg.ClaudeBin; } /// Builds a LaunchSpec for opening an interactive session in a task's worktree. /// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException /// if it's Running/Queued. If the task has no usable worktree yet, one is created on /// demand (same mechanism as an autonomous run) provided the task's list has a working /// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes /// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if /// it has one, else the latest autonomous run's session; a task that has never run either /// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre- /// assigned a new session id via --session-id and persisted to InteractiveSessionId before /// launch, so a closed/aborted session can be resumed next time. 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; string sessionDir; bool sessionIsWorktree; 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). // If the list's directory isn't a git repo at all, there is nothing to branch from -- // open the session directly in that directory instead, worktree-less. This mirrors an // existing shape in the codebase (a list-handler task also has no worktree and commits // straight to the list's working dir), so no WorktreeEntity is created here either. 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."); if (await _git.IsGitRepoAsync(list.WorkingDir, ct)) { // A stale (Merged/Discarded) row is already tracked in `ctx`'s identity map from // the read above -- CreateAsync replaces it via its OWN context, so re-querying on // `ctx` without detaching first would hand back the old cached instance instead of // the freshly created row. if (worktree is not null) ctx.Entry(worktree).State = EntityState.Detached; 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; sessionDir = worktree.Path; sessionIsWorktree = true; } else { sessionDir = list.WorkingDir; sessionIsWorktree = false; } } else if (!Directory.Exists(worktree.Path)) { throw new InvalidOperationException("The task's worktree directory no longer exists."); } else { sessionDir = worktree.Path; sessionIsWorktree = true; } 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(sessionDir, skillNames, isWorktree: sessionIsWorktree, ct); var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); // 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. var effort = EffortFor(globalSettings, task.Model ?? listConfig?.Model); // Resume this task's own last interactive conversation when it has one -- it takes // precedence over the latest autonomous run's session, since an interactive session is a // distinct conversation from an autonomous run even against the same worktree. Fall back // to the autonomous run's session so opening a task interactively for the first time still // picks up prior context. Neither survives a freshly (re)created worktree (isFreshWorktree // already forced `run` to null above). var resumeSessionId = isFreshWorktree ? null : task.InteractiveSessionId ?? run?.SessionId; // For a fresh session, seed the interactive TUI with the task's brief (title + // description) via a file, never as a positional CLI argument -- see // BuildFreshTaskArgsAsync for why. The session id claude will run under is generated and // persisted HERE, before launch, so a closed/aborted session -- even one that never got // past startup -- still leaves an id the next open can resume. IReadOnlyList args; if (resumeSessionId is { Length: > 0 }) { args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort); } else { var sessionId = Guid.NewGuid().ToString(); await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct); args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct); } // Same run environment variable ClaudeProcess sets for every headless run: the // AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting // it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT. var env = new Dictionary { ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs, }; return new LaunchSpec(sessionDir, resolvedClaude, args, env); } /// Maps an already-prepared planning START context (worktree + prompt files + token, /// produced by PlanningSessionManager.StartAsync) into a LaunchSpec for an embedded ConPTY /// planning session — same planning CLI args as the wt launcher, planning env carried in 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"] = McpToolTimeoutMs, }; return new LaunchSpec( ctx.WorkingDir, resolvedClaude, WithEffort(WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)), env); } /// Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a /// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume). 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"] = McpToolTimeoutMs, }; return new LaunchSpec( ctx.WorkingDir, resolvedClaude, WithEffort(WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)), env); } /// Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory -- /// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the /// directory doesn't exist. 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"] = McpToolTimeoutMs, }; // 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, web/skill lookups, and Task so the Merge phase // can delegate diff reviews to sonnet subagents instead of reading every diff itself. private const string MergeHelperAllowedTools = "mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task"; /// Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the /// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session /// system prompt + task brief under ~/.claudeDo/merge-helper-sessions/<guid> and exposes /// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory. /// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so /// the session can call handoff_list_handler/submit_task_for_review on its own handler task. /// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if /// taskIds is empty or the list has no existing working directory. public async Task BuildForMergeHelperAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, 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"); repoDir = TrimTrailingSeparator(repoDir); 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(RenderBriefEntry(task)); } 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.MergeHelperTriage), ct); var briefPath = Path.Combine(sessionDir, "brief.md"); await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial, new Dictionary { ["scope"] = $"List: {list.Name}", ["repo"] = repoDir, ["handlerTaskId"] = handlerTaskId, ["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. // Model/effort/permission-mode are fixed to the Triage role -- the list config's model no // longer influences a handler session at all, it would otherwise apply the LIST's effort // preset to a completely different model. var settings = await new AppSettingsRepository(ctx).GetAsync(ct); var args = new List { "--model", ModelRegistry.HandlerTriageAlias, "--effort", EffortFor(settings, ModelRegistry.HandlerTriageAlias), "--permission-mode", PermissionModeResolver.Resolve(ModelRegistry.HandlerTriageAlias, "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"] = McpToolTimeoutMs, }; return new LaunchSpec(repoDir, resolvedClaude, args, env); } // Builds the LaunchSpec for the fresh session a merge-helper run hands off to for the given // phase -- SAME handler task id as the run that called handoff_list_handler, so // HandlerBaseCommit/HandlerHeadCommit and the review range stay untouched; this never creates // a task. nextPhase picks the system prompt (MergeHelperWait for wait/wait_final, // MergeHelperMerge for merge/merge_final) so the handoff session only ever carries the // instructions for the role it is actually about to run, never the Triage dedupe/enhance ones. // Writes a fresh handoff kickoff file in a NEW session dir -- the old ConPTY tile keeps running // against its own session-dir files untouched. /// Builds a LaunchSpec for the fresh ConPTY session a merge-helper run hands off to for /// the given phase -- reuses the SAME handler task id (no new task, HandlerBaseCommit untouched), /// writing only a fresh handoff kickoff naming the surviving tasks. nextPhase selects both the /// system prompt and the model: "wait"/"wait_final" -> MergeHelperWait + HandlerWaitAlias, /// "merge"/"merge_final" -> MergeHelperMerge + HandlerMergeAlias; the "_final" variants render an /// extra line in the handoff kickoff marking the final round and forbidding further reruns. Throws /// ArgumentException for an unrecognized nextPhase; KeyNotFoundException if the task/list doesn't /// exist; InvalidOperationException if survivingTaskIds is empty or the list has no working /// directory. public async Task BuildForMergeHelperHandoffAsync( string taskId, IReadOnlyList survivingTaskIds, string nextPhase, CancellationToken ct) { if (survivingTaskIds.Count == 0) throw new InvalidOperationException("No surviving tasks to hand off."); var phase = ResolveHandoffPhase(nextPhase); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var taskRepo = new TaskRepository(ctx); var listRepo = new ListRepository(ctx); var handlerTask = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}"); var list = await listRepo.GetByIdAsync(handlerTask.ListId, ct) ?? throw new KeyNotFoundException($"List not found: {handlerTask.ListId}"); var repoDir = list.WorkingDir; if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir)) throw new InvalidOperationException($"list '{list.Name}' has no existing working directory"); repoDir = TrimTrailingSeparator(repoDir); var briefLines = new List(); foreach (var id in survivingTaskIds) { 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(phase.PromptKind), ct); // A "_final" round differs from its non-final counterpart in exactly one rendered line -- // the same system prompt and model drive both -- marking the final round and forbidding a // further rerun, so the chain is bounded structurally rather than by prompt appeal alone. var finalNote = phase.IsFinal ? "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " + "merge what you can, report the rest, and print the summary." : ""; var briefPath = Path.Combine(sessionDir, "handoff.md"); await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff, new Dictionary { ["scope"] = $"List: {list.Name}", ["repo"] = repoDir, ["handlerTaskId"] = taskId, ["tasks"] = string.Join("\n", briefLines), ["finalNote"] = finalNote, }), ct); var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); var settings = await new AppSettingsRepository(ctx).GetAsync(ct); var args = new List { "--model", phase.Model, "--effort", EffortFor(settings, phase.Model), "--permission-mode", PermissionModeResolver.Resolve(phase.Model, "auto"), "--allowedTools", MergeHelperAllowedTools, "--add-dir", sessionDir, repoDir, "--append-system-prompt-file", systemPromptPath, $"Read the file {briefPath} first. It lists the surviving tasks and their status. " + $"After reading it, begin at {phase.PhaseLabel} as your instructions describe.", }; var env = new Dictionary { ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs, }; return new LaunchSpec(repoDir, resolvedClaude, args, env); } // The ConPTY host flattens Args into ONE Windows command line and quotes each token, so a token // ending in '\' escapes its own closing quote and every following argument is absorbed into the // preceding variadic flag -- for a list handler that means --add-dir swallows // --append-system-prompt-file AND the positional kickoff, and the session opens with no prompt // at all. ListRepository normalizes on write now, but rows written before that still carry one. private static string TrimTrailingSeparator(string dir) => Paths.TrimTrailingSeparator(dir)!; // Renders one task as a brief list item. A description can itself be arbitrary Markdown // (headings, lists, fenced code) — those must not merge into the brief's own task list, so // the description is placed in a fenced code block indented to the list item's continuation // column (2 spaces, matching "- "). That keeps CommonMark parsing the fence as part of THIS // bullet rather than breaking the list, while the code fence itself stops any inner heading // or list syntax from being interpreted. The fence length is extended past the longest run of // backticks already present in the description so an embedded ``` block can't prematurely // close it. private static string RenderBriefEntry(TaskEntity task) { var header = $"- [{task.Status}] {task.Title} (id: {task.Id})"; var description = task.Description?.Trim(); if (string.IsNullOrEmpty(description)) return header; var fence = new string('`', Math.Max(3, LongestBacktickRun(description) + 1)); var lines = new List(4) { header, $" {fence}" }; lines.AddRange(description.Replace("\r\n", "\n").Split('\n').Select(line => $" {line}")); lines.Add($" {fence}"); return string.Join("\n", lines); } private static int LongestBacktickRun(string text) { var max = 0; var current = 0; foreach (var ch in text) { current = ch == '`' ? current + 1 : 0; if (current > max) max = current; } return max; } // Creates the ClaudeDo task that hosts a list-handler run (Mission Control's "Let Claude // handle it") and stamps the list repo's current HEAD as the review range's base commit. // The handler never gets its own worktree -- it commits straight to the list's working // dir -- so this HandlerBaseCommit/HandlerHeadCommit pair (see TaskEntity) is what lets the // normal diff/get_task_diff paths show what the run changed once it submits for review. // IsManual=true so the queue picker, daily prep, and the "send to queue"/"refine" UI // affordances all skip it, matching the "reminder only a human/ConPTY session can act on" // semantics IsManual already carries elsewhere; the ConPTY session itself is still allowed. /// Creates the ClaudeDo task that hosts a list-handler run (Mission Control's /// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's /// base commit (see TaskEntity.HandlerBaseCommit). Returns the new task's id. Throws /// KeyNotFoundException if the list doesn't exist; InvalidOperationException if taskIds /// is empty or the list has no existing working directory. public async Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, 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 listRepo = new ListRepository(ctx); var taskRepo = new TaskRepository(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 descriptionLines = new List(); foreach (var id in taskIds) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is not null) descriptionLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})"); } var baseCommit = await _git.RevParseHeadAsync(repoDir, ct); var handlerTask = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Description = descriptionLines.Count > 0 ? $"{descriptionHeader}\n{string.Join("\n", descriptionLines)}" : descriptionHeader, IsManual = true, HandlerBaseCommit = baseCommit, CreatedAt = DateTime.UtcNow, }; await taskRepo.AddAsync(handlerTask, ct); return handlerTask.Id; } // Maps a handoff nextPhase to the system prompt/model the next session runs under, plus the // "phase N" label used in its short positional kickoff. wait/wait_final share MergeHelperWait // + HandlerWaitAlias; merge/merge_final share MergeHelperMerge + HandlerMergeAlias -- only // IsFinal differs between a phase and its "_final" counterpart. private static (PromptKind PromptKind, string Model, string PhaseLabel, bool IsFinal) ResolveHandoffPhase(string nextPhase) { MergeHelperPhase.Validate(nextPhase); return nextPhase switch { MergeHelperPhase.Wait => (PromptKind.MergeHelperWait, ModelRegistry.HandlerWaitAlias, "phase 3", false), MergeHelperPhase.WaitFinal => (PromptKind.MergeHelperWait, ModelRegistry.HandlerWaitAlias, "phase 3", true), MergeHelperPhase.Merge => (PromptKind.MergeHelperMerge, ModelRegistry.HandlerMergeAlias, "phase 4", false), MergeHelperPhase.MergeFinal => (PromptKind.MergeHelperMerge, ModelRegistry.HandlerMergeAlias, "phase 4", true), _ => throw new UnreachableException(), }; } // 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 task's brief (title + description, unmodified/multi-line) must never travel as a CLI // argument: the ConPTY host flattens Args into one command line to spawn the process, and // claude re-splits that line on whitespace, so any token in the description that starts with // '-' (e.g. "->", "--abort") gets misread as an unknown option, and a newline would truncate // the brief at its first line even if it didn't. So it's written to a file instead -- // same pattern as BuildForMergeHelperAsync / WindowsTerminalLauncher.BuildPlanningStartArgs -- // and claude is pointed at it with a single-line kickoff. --add-dir exposes the session dir to // Read; --effort (single-value) must sit directly before the positional kickoff so the // preceding variadic --add-dir doesn't swallow the kickoff as another directory. // No brief (task has neither a title nor a description) -> no positional arg at all. // `--session-id` pre-assigns the claude session id the caller already persisted (see // BuildForTaskAsync) so this fresh conversation is resumable from its very first turn -- // it's a single-value flag, so it may sit directly before the positional kickoff. private static async Task> BuildFreshTaskArgsAsync( TaskEntity task, string effort, string sessionId, CancellationToken ct) { var brief = BuildTaskBrief(task); if (string.IsNullOrEmpty(brief)) return new[] { "--effort", effort, "--session-id", sessionId }; var sessionDir = Path.Combine(Paths.AppDataRoot(), "task-sessions", task.Id); Directory.CreateDirectory(sessionDir); var briefPath = Path.Combine(sessionDir, "brief.md"); await File.WriteAllTextAsync(briefPath, brief, ct); return new[] { "--add-dir", sessionDir, "--effort", effort, "--session-id", sessionId, $"Read the file {briefPath} first. It contains the task you must work on. " + "After reading it, begin the session as your instructions describe.", }; } private static string BuildTaskBrief(TaskEntity task) { var title = task.Title?.Trim(); var description = task.Description?.Trim(); return (string.IsNullOrEmpty(title), string.IsNullOrEmpty(description)) switch { (false, false) => $"{title}\n\n{description}", (false, true) => title!, (true, false) => description!, _ => string.Empty, }; } 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(); } }