feat/planning-sessions-worker #7
12
src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
Normal file
12
src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
Normal file
@@ -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) { }
|
||||||
|
}
|
||||||
132
src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
Normal file
132
src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
Normal file
@@ -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 <path> (file form)
|
||||||
|
// Session ID: no pre-assign flag; resume with --resume <id>
|
||||||
|
|
||||||
|
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 <args> "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 <workingDir> cmd /k "<cmdLine>"
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PlanningLaunchException>(() =>
|
||||||
|
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<PlanningLaunchException>(() =>
|
||||||
|
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
||||||
|
Assert.Contains("Windows Terminal", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user