// Claude CLI flags (verified 2026-04-23 via Context7): // Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists) // Allowed-tools: --allowedTools (camelCase), comma-separated tokens // System prompt: --append-system-prompt-file (file form) // Extra read roots: --add-dir (variadic) // Session ID: no pre-assign flag; resume with --resume // Launch model: wt.exe -> powershell -> claude.exe (UseShellExecute=false). // wt.exe treats ';' as a tab/command delimiter in EVERY argument, regardless of // quoting (microsoft/terminal#13264), so nothing containing ';' may appear on the wt // command line. Every token we place there is a controlled constant or a filesystem // path under ~/.todo-app/sessions/ — none can contain ';' — and each is // single-quoted for PowerShell. // // The free-text task brief is NEVER passed as an argument. An interactive `claude` // session auto-submits its positional prompt, and a newline in that prompt ends the // first line — a multi-line brief was silently truncated at the first newline. So we // hand claude a single-line kickoff that points it at the brief FILE (written by // PlanningSessionManager) and expose that file's directory via --add-dir so the Read // tool can open it. The full multi-line brief reaches claude intact via the file. using System.Diagnostics; using System.Text; using ClaudeDo.Data.Models; namespace ClaudeDo.Worker.Planning; // Spawns the Claude CLI inside a visible Windows Terminal window. Used for every // human-driven session: an interactive planning session (start/resume) and the ad-hoc // "Run interactively" action. Headless task execution does NOT come through here — that // path is ClaudeProcess (prompt over stdin, no terminal). public sealed class WindowsTerminalLauncher : ITerminalLauncher { private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill"; private const string Model = ModelRegistry.PlanningAlias; private readonly string _wtPath; private readonly string _claudePath; public WindowsTerminalLauncher(string wtPath, string claudePath) { _wtPath = wtPath; _claudePath = claudePath; } public Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) { if (!Directory.Exists(ctx.WorkingDir)) throw new TerminalLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); if (!File.Exists(ctx.Files.SystemPromptPath)) throw new TerminalLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}"); if (!File.Exists(ctx.Files.InitialPromptPath)) throw new TerminalLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}"); var resolvedWt = ResolveWtOrThrow(); var resolvedClaude = ResolveClaudeOrThrow(); var command = BuildPlanningStartCommand(resolvedClaude, ctx); StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env => { env["MAX_THINKING_TOKENS"] = "20000"; env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token; }); return Task.CompletedTask; } public Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) { if (!Directory.Exists(ctx.WorkingDir)) throw new TerminalLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); var resolvedWt = ResolveWtOrThrow(); var resolvedClaude = ResolveClaudeOrThrow(); var command = BuildPwshCommand(resolvedClaude, new[] { "--permission-mode", "plan", "--resume", ctx.ClaudeSessionId, }); StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env => { env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token; }); return Task.CompletedTask; } public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken) { if (!Directory.Exists(workingDir)) throw new TerminalLaunchException($"Working directory does not exist: {workingDir}"); var resolvedWt = ResolveWtOrThrow(); var resolvedClaude = ResolveClaudeOrThrow(); var command = BuildResumeCommand(resolvedClaude, claudeSessionId); StartInWindowsTerminal(resolvedWt, workingDir, command, static _ => { }); return Task.CompletedTask; } // Resumes a session by id in default (interactive) permission mode: the user drives // tool approvals in the terminal, unlike planning which pins --permission-mode plan. internal static string BuildResumeCommand(string claudePath, string claudeSessionId) => BuildPwshCommand(claudePath, new[] { "--resume", claudeSessionId }); // Builds the PowerShell command that launches an interactive planning session. // Arg order matters: variadic flags (--allowedTools, --add-dir) come first; the // single-line kickoff prompt is positional, so it must follow a single-value flag // (--append-system-prompt-file) or a variadic flag would swallow it. internal static string BuildPlanningStartCommand(string claudePath, PlanningSessionStartContext ctx) { var kickoff = $"Read the file {ctx.Files.InitialPromptPath} first. It contains the task you must plan. " + "After reading it, begin the planning session as your instructions describe."; return BuildPwshCommand(claudePath, new[] { "--model", Model, "--permission-mode", "plan", "--allowedTools", AllowedTools, "--add-dir", ctx.Files.SessionDirectory, "--append-system-prompt-file", ctx.Files.SystemPromptPath, kickoff, }); } private string ResolveWtOrThrow() => Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}"); private string ResolveClaudeOrThrow() => Resolve(_claudePath) ?? throw new TerminalLaunchException($"claude executable not found: {_claudePath}"); // Builds the PowerShell command that invokes claude with the given tokens. Each token // is single-quoted, so ';', '&', spaces, quotes, and backslashes pass through to the // child process untouched. No cmd shim: cmd would re-parse the arguments. private static string BuildPwshCommand(string claudePath, IReadOnlyList args) { var sb = new StringBuilder(); sb.Append("& ").Append(PwshQuote(claudePath)); foreach (var a in args) sb.Append(' ').Append(PwshQuote(a)); return sb.ToString(); } // Single-quote for PowerShell: only the single quote itself is special, escaped by doubling. private static string PwshQuote(string value) => "'" + value.Replace("'", "''") + "'"; private static void StartInWindowsTerminal( string resolvedWt, string workingDir, string pwshCommand, Action> configureEnv) { var psi = new ProcessStartInfo { FileName = resolvedWt, UseShellExecute = false, CreateNoWindow = false, }; configureEnv(psi.Environment); psi.ArgumentList.Add("-d"); psi.ArgumentList.Add(workingDir); psi.ArgumentList.Add("powershell"); psi.ArgumentList.Add("-NoProfile"); psi.ArgumentList.Add("-NoLogo"); psi.ArgumentList.Add("-Command"); psi.ArgumentList.Add(pwshCommand); _ = Process.Start(psi) ?? throw new TerminalLaunchException("Failed to start Windows Terminal process."); } private static string? Resolve(string pathOrName) { if (File.Exists(pathOrName)) return pathOrName; // Try PATH resolution var envPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; var extensions = new[] { "", ".exe", ".cmd", ".bat" }; foreach (var dir in envPath.Split(Path.PathSeparator)) { foreach (var ext in extensions) { var candidate = Path.Combine(dir, pathOrName + ext); if (File.Exists(candidate)) return candidate; } } return null; } }