Files
ClaudeDo/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
T
mika kuns f70503d038 fix(worker): interaktive Session ohne Arbeitsverzeichnis im User-Verzeichnis starten
Eine manuelle Liste (Erinnerungen) hat kein WorkingDir. BuildForTaskAsync
warf dafuer bisher "no working directory configured", die interaktive
Session liess sich also nicht oeffnen. Jetzt faellt der Pfad auf das
User-Verzeichnis zurueck; Session-Skills werden dort bewusst nicht
geseedet, weil das Ziel das echte ~/.claude/skills waere.
2026-09-03 10:17:06 +02:00

735 lines
40 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.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<ClaudeDoDbContext> _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<DateTime> _now;
public InteractiveLaunchSpecService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ISessionSkillSeeder skillSeeder,
ISessionSkillRegistry skillRegistry,
WorktreeManager wtManager,
GitService git,
WorkerConfig cfg,
HandoffRoundTracker handoffRounds,
HandlerRoundState roundState,
Func<DateTime>? now = null)
{
_now = now ?? (() => DateTime.Now);
_dbFactory = dbFactory;
_skillSeeder = skillSeeder;
_skillRegistry = skillRegistry;
_wtManager = wtManager;
_git = git;
_claudePath = cfg.ClaudeBin;
_handoffRounds = handoffRounds;
_roundState = roundState;
}
/// <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; 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.</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;
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<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 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<string, string>
{
["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 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,
};
// 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);
}
/// <summary>The <see cref="HandlerRound.Phase"/> marker for the initial (dedupe/enhance) handler
/// session. Deliberately not a <see cref="MergeHelperPhase"/> value — that set is exactly the
/// phases <see cref="BuildForMergeHelperHandoffAsync"/> can rebuild.</summary>
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.
/// <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 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<string, string>
{
["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<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,
};
// 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<string>();
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<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;
}
// The list-handler system prompt, plus the PowerUse addendum when this run is in PowerUse.
//
// PowerUse requires BOTH the global switch (AppSettingsEntity.PowerUseEnabled, toggled on the
// usage monitor) 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<string> 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 <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();
}
}