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.External; 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; private readonly HandoffRoundTracker _handoffRounds; private readonly HandlerRoundState _roundState; // Local wall clock, injectable so the PowerUse time window is testable without sleeping. private readonly Func _now; public InteractiveLaunchSpecService( IDbContextFactory dbFactory, ISessionSkillSeeder skillSeeder, ISessionSkillRegistry skillRegistry, WorktreeManager wtManager, GitService git, WorkerConfig cfg, HandoffRoundTracker handoffRounds, HandlerRoundState roundState, Func? now = null) { _now = now ?? (() => DateTime.Now); _dbFactory = dbFactory; _skillSeeder = skillSeeder; _skillRegistry = skillRegistry; _wtManager = wtManager; _git = git; _claudePath = cfg.ClaudeBin; _handoffRounds = handoffRounds; _roundState = roundState; } /// 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; a non-repo directory is opened directly, and a list with /// no working directory at all (manual/reminder list) opens in the user's home dir. 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; var seedSkills = true; 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 is null || string.IsNullOrEmpty(list.WorkingDir)) { // A manual/reminder list has no repo at all -- open the session in the user's // home directory instead of refusing it. Skills are deliberately NOT seeded // there: the destination would be the user's REAL ~/.claude/skills, not a // throwaway session copy. sessionDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); sessionIsWorktree = false; seedSkills = false; } else 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); if (seedSkills) { 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. /// model overrides the triage session's model (start-modal dropdown); null/unknown falls /// back to . Handoff phases are unaffected. public async Task BuildForMergeHelperAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, string? model, 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 briefEntries = new List<(TaskEntity Task, string Line)>(); foreach (var id in taskIds) { var task = await taskRepo.GetByIdAsync(id, ct) ?? throw new KeyNotFoundException($"Task not found: {id}"); briefEntries.Add((task, 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, await ComposeHandlerPromptAsync(ctx, listId, PromptKind.MergeHelperTriage, ct), 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"] = RenderGroupedBrief(briefEntries), }), 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 triageModel = ModelRegistry.TryNormalizeAlias(model) ?? ModelRegistry.HandlerTriageAlias; var args = new List { "--model", triageModel, "--effort", EffortFor(settings, triageModel), "--permission-mode", PermissionModeResolver.Resolve(triageModel, "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, }; // The opening round. Recorded so anything watching a handler run knows which round is live // (the deferred-handoff follow-up needs that), but marked "triage" rather than a // MergeHelperPhase value on purpose: there is no handoff spec for this phase, so // HandlerRestartCoordinator will not reopen it. _roundState.Save(new HandlerRound(handlerTaskId, TriagePhase, taskIds, DateTime.UtcNow)); return new LaunchSpec(repoDir, resolvedClaude, args, env); } /// The marker for the initial (dedupe/enhance) handler /// session. Deliberately not a value — that set is exactly the /// phases can rebuild. public const string TriagePhase = "triage"; // 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 briefEntries = new List<(TaskEntity Task, string Line)>(); foreach (var id in survivingTaskIds) { var task = await taskRepo.GetByIdAsync(id, ct) ?? throw new KeyNotFoundException($"Task not found: {id}"); briefEntries.Add((task, $"- [{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, await ComposeHandlerPromptAsync(ctx, handlerTask.ListId, phase.PromptKind, ct), ct); // The round count/cap come from HandoffRoundTracker -- the same instance HandoffMcpTools // registered this handoff against -- never recomputed here, so the brief can't drift from // the server-side cap that actually enforces the chain. The cap is the CONFIGURED one // (ListHandlerMaxHandoffs), read through GetStatus so the clamp and the arithmetic live in // one place; hardcoding the default here would understate a raised budget and tell the model // it has two rounds left when it really has dozens. A "_final" round adds one more line // forbidding a further rerun, so the chain is bounded structurally rather than by prompt // appeal alone. var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(ct)).ListHandlerMaxHandoffs; var round = _handoffRounds.GetStatus(taskId, maxHandoffs); var roundInfo = $"Handoff budget: this is round {round.Round} of " + $"{Math.Max(1, maxHandoffs)} possible handoffs for this list-handler run " + $"({round.Remaining} more possible after this one)."; if (phase.IsFinal) roundInfo += "\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"] = RenderGroupedBrief(briefEntries), ["roundInfo"] = roundInfo, }), 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, }; // Persist the round that is about to start. Phase + task set only exist in the transient // HandoffRequested broadcast otherwise, so without this a dead tile cannot be reopened at // the same phase with the same set (see HandlerRestartCoordinator). _roundState.Save(new HandlerRound(taskId, nextPhase, survivingTaskIds, DateTime.UtcNow)); 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)!; // A task the picker would still touch (or is mid-run) vs. one that already landed // somewhere terminal-ish. Split the brief on this so a handler session picking up after a // Wait phase can see at a glance which of its tasks are actually ready to review and which // are still moving underneath it -- the flat list this replaces made every task look // equally "done" regardless of status. private static readonly TaskStatus[] StillRunningStatuses = { TaskStatus.Queued, TaskStatus.Running, TaskStatus.WaitingForChildren }; private static string RenderGroupedBrief(IReadOnlyList<(TaskEntity Task, string Line)> entries) { var finished = entries.Where(e => !StillRunningStatuses.Contains(e.Task.Status)).Select(e => e.Line).ToList(); var running = entries.Where(e => StillRunningStatuses.Contains(e.Task.Status)).Select(e => e.Line).ToList(); var sections = new List(); if (finished.Count > 0) sections.Add("### Done / waiting for review\n" + string.Join("\n", finished)); if (running.Count > 0) sections.Add("### Still running\n" + string.Join("\n", running)); return string.Join("\n\n", sections); } // 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; } // The list-handler system prompt, plus the PowerUse addendum when this run is in PowerUse. // // PowerUse requires BOTH the global switch (AppSettingsEntity.PowerUseEnabled, Settings → // Execution) AND the list's own opt-in (ListConfigEntity.PowerUseAllowed). That is the whole // point of the two-key design: the global switch can be left on overnight without turning some // unrelated list's handler run unattended, because a list that never opted in keeps the // interactive prompt unchanged. // // Appending rather than substituting a second prompt set keeps the shared 95% in one place -- // the addendum is written to read as an override of the base prompt's interactive rules. A run // that is not in PowerUse gets a byte-identical prompt to what it got before PowerUse existed. // // The optional time window is checked HERE, i.e. per phase at prompt-build time: a chain that // hands off out of the window drops the addendum from that point on instead of staying // unattended because the first phase happened to start inside it. private async Task ComposeHandlerPromptAsync( ClaudeDoDbContext ctx, string listId, PromptKind kind, CancellationToken ct) { var basePrompt = PromptFiles.ReadOrDefault(kind); var settings = await new AppSettingsRepository(ctx).GetAsync(ct); if (!settings.PowerUseEnabled) return basePrompt; if (!PowerUseWindow.IsOpen(_now(), settings.PowerUseWindowStart, settings.PowerUseWindowEnd)) return basePrompt; var listConfig = await new ListRepository(ctx).GetConfigAsync(listId, ct); if (listConfig?.PowerUseAllowed != true) return basePrompt; return basePrompt + "\n\n" + PromptFiles.ReadOrDefault(PromptKind.PowerUseAddendum); } // 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(); } }