From 43a374098064e6c4ffb9d1412d0b19f0e0386f94 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 21:08:15 +0200 Subject: [PATCH] feat(worker): WindowsTerminalPlanningLauncher with pre-flight checks --- .../Planning/IPlanningTerminalLauncher.cs | 12 ++ .../WindowsTerminalPlanningLauncher.cs | 132 ++++++++++++++++++ .../WindowsTerminalPlanningLauncherTests.cs | 47 +++++++ 3 files changed, 191 insertions(+) create mode 100644 src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs create mode 100644 src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs diff --git a/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs new file mode 100644 index 0000000..ddbe016 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs @@ -0,0 +1,12 @@ +namespace ClaudeDo.Worker.Planning; + +public interface IPlanningTerminalLauncher +{ + Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken); + Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken); +} + +public sealed class PlanningLaunchException : Exception +{ + public PlanningLaunchException(string message) : base(message) { } +} diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs new file mode 100644 index 0000000..a38948c --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs @@ -0,0 +1,132 @@ +// 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) +// Session ID: no pre-assign flag; resume with --resume + +using System.Diagnostics; + +namespace ClaudeDo.Worker.Planning; + +public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher +{ + private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill"; + private const string Model = "claude-sonnet-4-6"; + + private readonly string _wtPath; + private readonly string _claudePath; + + public WindowsTerminalPlanningLauncher(string wtPath, string claudePath) + { + _wtPath = wtPath; + _claudePath = claudePath; + } + + public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); + + if (!File.Exists(ctx.Files.McpConfigPath)) + throw new PlanningLaunchException($"MCP config file not found: {ctx.Files.McpConfigPath}"); + + var resolvedWt = Resolve(_wtPath); + if (resolvedWt is null) + throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); + + var resolvedClaude = Resolve(_claudePath); + if (resolvedClaude is null) + throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); + + var initialPrompt = File.Exists(ctx.Files.InitialPromptPath) + ? File.ReadAllText(ctx.Files.InitialPromptPath) + : string.Empty; + + // Build cmd line: set MAX_THINKING_TOKENS=20000 && claude "prompt" + // UseShellExecute=true means we cannot set psi.Environment, so we inject via cmd /k. + var claudeArgs = BuildStartArgs(ctx, initialPrompt, resolvedClaude); + var cmdLine = $"set MAX_THINKING_TOKENS=20000 && {claudeArgs}"; + + var psi = new ProcessStartInfo + { + FileName = resolvedWt, + UseShellExecute = true, + WorkingDirectory = ctx.WorkingDir, + }; + // wt.exe -d cmd /k "" + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(ctx.WorkingDir); + psi.ArgumentList.Add("cmd"); + psi.ArgumentList.Add("/k"); + psi.ArgumentList.Add(cmdLine); + + Process.Start(psi); + return Task.CompletedTask; + } + + public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); + + if (!File.Exists(ctx.McpConfigPath)) + throw new PlanningLaunchException($"MCP config file not found: {ctx.McpConfigPath}"); + + var resolvedWt = Resolve(_wtPath); + if (resolvedWt is null) + throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); + + var resolvedClaude = Resolve(_claudePath); + if (resolvedClaude is null) + throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); + + var psi = new ProcessStartInfo + { + FileName = resolvedWt, + UseShellExecute = true, + WorkingDirectory = ctx.WorkingDir, + }; + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(ctx.WorkingDir); + psi.ArgumentList.Add("cmd"); + psi.ArgumentList.Add("/k"); + psi.ArgumentList.Add(BuildResumeArgs(ctx, resolvedClaude)); + + Process.Start(psi); + return Task.CompletedTask; + } + + private static string BuildStartArgs(PlanningSessionStartContext ctx, string initialPrompt, string claudePath) + { + // Build as a flat string for cmd /k; quote paths that may contain spaces. + return $"\"{claudePath}\" --mcp-config \"{ctx.Files.McpConfigPath}\" --append-system-prompt-file \"{ctx.Files.SystemPromptPath}\" --allowedTools \"{AllowedTools}\" --model {Model} \"{EscapeArg(initialPrompt)}\""; + } + + private static string BuildResumeArgs(PlanningSessionResumeContext ctx, string claudePath) + { + return $"\"{claudePath}\" --resume {ctx.ClaudeSessionId} --mcp-config \"{ctx.McpConfigPath}\""; + } + + private static string EscapeArg(string value) => value.Replace("\"", "\\\""); + + 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; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs new file mode 100644 index 0000000..cc19d05 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs @@ -0,0 +1,47 @@ +using ClaudeDo.Worker.Planning; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class WindowsTerminalPlanningLauncherTests +{ + private static PlanningSessionStartContext MakeStartCtx(string? wd = null) + { + var workingDir = wd ?? Path.GetTempPath(); + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(dir); + return new PlanningSessionStartContext( + ParentTaskId: "task-1", + WorkingDir: workingDir, + Files: new PlanningSessionFiles( + SessionDirectory: dir, + McpConfigPath: Path.Combine(dir, "mcp.json"), + SystemPromptPath: Path.Combine(dir, "system-prompt.md"), + InitialPromptPath: Path.Combine(dir, "initial-prompt.txt"))); + } + + [Fact] + public async Task LaunchStartAsync_WorkingDirMissing_Throws() + { + var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid())); + var sut = new WindowsTerminalPlanningLauncher(wtPath: "wt", claudePath: "claude"); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchStartAsync(ctx, CancellationToken.None)); + Assert.Contains("Working directory", ex.Message); + } + + [Fact] + public async Task LaunchStartAsync_WtMissing_Throws() + { + var ctx = MakeStartCtx(); + File.WriteAllText(ctx.Files.McpConfigPath, "{}"); + File.WriteAllText(ctx.Files.SystemPromptPath, "sp"); + File.WriteAllText(ctx.Files.InitialPromptPath, "ip"); + + var sut = new WindowsTerminalPlanningLauncher( + wtPath: "C:/no/such/wt.exe", + claudePath: "claude"); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchStartAsync(ctx, CancellationToken.None)); + Assert.Contains("Windows Terminal", ex.Message); + } +}