Files
ClaudeDo/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
T

631 lines
33 KiB
C#

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<ClaudeDoDbContext> _dbFactory;
private readonly ISessionSkillSeeder _skillSeeder;
private readonly ISessionSkillRegistry _skillRegistry;
private readonly WorktreeManager _wtManager;
private readonly GitService _git;
private readonly string _claudePath;
public InteractiveLaunchSpecService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ISessionSkillSeeder skillSeeder,
ISessionSkillRegistry skillRegistry,
WorktreeManager wtManager,
GitService git,
WorkerConfig cfg)
{
_dbFactory = dbFactory;
_skillSeeder = skillSeeder;
_skillRegistry = skillRegistry;
_wtManager = wtManager;
_git = git;
_claudePath = cfg.ClaudeBin;
}
/// <summary>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.</summary>
public async Task<LaunchSpec> 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<string> 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<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(sessionDir, resolvedClaude, args, env);
}
/// <summary>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.</summary>
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<string, string>
{
["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);
}
/// <summary>Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a
/// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume).</summary>
public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
{
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
var env = new Dictionary<string, string>
{
["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);
}
/// <summary>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.</summary>
public async Task<LaunchSpec> 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<string, string>
{
["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<string>(), 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";
/// <summary>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/&lt;guid&gt; 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.</summary>
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> 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<string>();
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<string, string>
{
["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<string>
{
"--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<string, string>
{
["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.
/// <summary>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.</summary>
public async Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
string taskId, IReadOnlyList<string> 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<string>();
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<string, string>
{
["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<string>
{
"--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<string, string>
{
["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<string>(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.
/// <summary>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.</summary>
public async Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> 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<string>();
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 <level>`. 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<string> WithEffort(IReadOnlyList<string> args, string effort)
{
var result = new List<string>(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<IReadOnlyList<string>> 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<IReadOnlyList<string>> FilterToInstalledSkillsAsync(IReadOnlyList<string> 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();
}
}