// 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 for an interactive // planning session (start/resume). Headless task execution does NOT come through here — // that path is ClaudeProcess (prompt over stdin, no terminal) — nor does an embedded // ConPTY interactive session (IInteractiveLaunchSpecService), which reuses this class's // arg-building helpers (BuildResumeArgs, BuildPlanningStart/ResumeArgs) for a bare // Exe/Args pair instead of a wrapped pwsh command line. 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, BuildPlanningResumeArgs(ctx.ClaudeSessionId)); StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env => { env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token; }); return Task.CompletedTask; } // The raw claude CLI args for a --resume launch, shared with InteractiveLaunchSpecService // (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line). internal static IReadOnlyList BuildResumeArgs(string claudeSessionId) => 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) => BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx)); // The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY // planning path (InteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than // a pwsh-wrapped command line. 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 IReadOnlyList BuildPlanningStartArgs(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 new[] { "--model", Model, // NOT --permission-mode plan: plan mode gates EVERY MCP tool call regardless of // --allowedTools, so the session would prompt the user on the first CreateChildTask // (the whole point of a planning session). Default mode honours the allowlist below, // and file edits stay blocked anyway — the planning system prompt forbids them and // AllowedTools omits Write/Edit/Bash (an unexpected edit would prompt, not run silently). "--permission-mode", "default", "--allowedTools", AllowedTools, "--add-dir", ctx.Files.SessionDirectory, "--append-system-prompt-file", ctx.Files.SystemPromptPath, kickoff, }; } // The raw claude CLI args for an interactive planning RESUME, shared with the embedded-ConPTY // planning path. Re-passes the planning allowlist in default mode (not --resume alone): the CLI // does not restore --allowedTools across a resume, and plan mode would gate the MCP tools. internal static IReadOnlyList BuildPlanningResumeArgs(string claudeSessionId) => new[] { "--permission-mode", "default", "--allowedTools", AllowedTools, "--resume", claudeSessionId }; 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."); } // Not private: reused by InteractiveLaunchSpecService to resolve the claude executable // for a ConPTY-hosted launch spec the same way a wt-launched terminal resolves it. internal 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; } }