fix(worker): keep interactive & planning prompts intact past Windows Terminal
wt.exe treats ';' as a command/tab delimiter in every argument, with no escape that survives quoting (microsoft/terminal#13264), so a task description containing ';' spawned extra terminals on "Run interactively" and planning start. Route the launch as wt -> powershell -> claude and pass the free-text prompt via $env:CLAUDEDO_LAUNCH_PROMPT so it never reaches the wt command line; PowerShell binds the variable as a single argument (embedded quotes escaped for PS 5.1). Also clarify the launcher, which serves interactive runs too (not just planning): IPlanningTerminalLauncher -> ITerminalLauncher, WindowsTerminalPlanningLauncher -> WindowsTerminalLauncher, LaunchStart/Resume -> LaunchPlanning{Start,Resume}Async.
This commit is contained in:
@@ -12,7 +12,7 @@ Worker/
|
|||||||
Worktrees/ — WorktreeMaintenanceService
|
Worktrees/ — WorktreeMaintenanceService
|
||||||
Agents/ — AgentFileService, DefaultAgentSeeder
|
Agents/ — AgentFileService, DefaultAgentSeeder
|
||||||
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
|
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
|
||||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalPlanningLauncher (IPlanningTerminalLauncher)
|
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning + interactive sessions
|
||||||
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
|
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
|
||||||
External/ — ExternalMcpService + sibling tool classes
|
External/ — ExternalMcpService + sibling tool classes
|
||||||
Config/ — WorkerConfig
|
Config/ — WorkerConfig
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
private readonly TaskResetService _resetService;
|
private readonly TaskResetService _resetService;
|
||||||
private readonly TaskMergeService _mergeService;
|
private readonly TaskMergeService _mergeService;
|
||||||
private readonly PlanningSessionManager _planning;
|
private readonly PlanningSessionManager _planning;
|
||||||
private readonly IPlanningTerminalLauncher _launcher;
|
private readonly ITerminalLauncher _launcher;
|
||||||
private readonly PlanningAggregator _planningAggregator;
|
private readonly PlanningAggregator _planningAggregator;
|
||||||
private readonly PlanningMergeOrchestrator _planningMergeOrchestrator;
|
private readonly PlanningMergeOrchestrator _planningMergeOrchestrator;
|
||||||
private readonly PlanningChainCoordinator _planningChain;
|
private readonly PlanningChainCoordinator _planningChain;
|
||||||
@@ -127,7 +127,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
TaskResetService resetService,
|
TaskResetService resetService,
|
||||||
TaskMergeService mergeService,
|
TaskMergeService mergeService,
|
||||||
PlanningSessionManager planning,
|
PlanningSessionManager planning,
|
||||||
IPlanningTerminalLauncher launcher,
|
ITerminalLauncher launcher,
|
||||||
PlanningAggregator planningAggregator,
|
PlanningAggregator planningAggregator,
|
||||||
PlanningMergeOrchestrator planningMergeOrchestrator,
|
PlanningMergeOrchestrator planningMergeOrchestrator,
|
||||||
PlanningChainCoordinator planningChain,
|
PlanningChainCoordinator planningChain,
|
||||||
@@ -533,9 +533,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
|
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted);
|
await _launcher.LaunchPlanningStartAsync(ctx, Context.ConnectionAborted);
|
||||||
}
|
}
|
||||||
catch (PlanningLaunchException)
|
catch (TerminalLaunchException)
|
||||||
{
|
{
|
||||||
// Launch failed before any children could be created; force-cleanup is safe.
|
// Launch failed before any children could be created; force-cleanup is safe.
|
||||||
await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
|
await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
|
||||||
@@ -548,7 +548,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
|
public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
|
||||||
{
|
{
|
||||||
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
|
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
|
||||||
await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted);
|
await _launcher.LaunchPlanningResumeAsync(ctx, Context.ConnectionAborted);
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace ClaudeDo.Worker.Planning;
|
|
||||||
|
|
||||||
public interface IPlanningTerminalLauncher
|
|
||||||
{
|
|
||||||
Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
|
||||||
Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
|
|
||||||
Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PlanningLaunchException : Exception
|
|
||||||
{
|
|
||||||
public PlanningLaunchException(string message) : base(message) { }
|
|
||||||
}
|
|
||||||
16
src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs
Normal file
16
src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
// Launches the Claude CLI in a visible terminal for human-driven sessions:
|
||||||
|
// planning (start/resume) and the ad-hoc "Run interactively" action. Not used for
|
||||||
|
// headless task execution (that path is ClaudeProcess, prompt over stdin).
|
||||||
|
public interface ITerminalLauncher
|
||||||
|
{
|
||||||
|
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
||||||
|
Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
|
||||||
|
Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TerminalLaunchException : Exception
|
||||||
|
{
|
||||||
|
public TerminalLaunchException(string message) : base(message) { }
|
||||||
|
}
|
||||||
192
src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
Normal file
192
src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// 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>
|
||||||
|
// Launch model: wt.exe -> powershell -> claude.exe (UseShellExecute=false).
|
||||||
|
// wt.exe treats ';' as a tab/command delimiter in EVERY argument, regardless of
|
||||||
|
// quoting, and there is no escape that survives (microsoft/terminal#13264). So the
|
||||||
|
// free-text prompt must never appear on the wt command line. We hand it to PowerShell
|
||||||
|
// out-of-band via an environment variable and reference it as $env:VAR — PowerShell
|
||||||
|
// binds a variable's value as a single argument without re-tokenizing it, so the prompt
|
||||||
|
// is robust to ';', '&', quotes, and newlines. All other (controlled) tokens are
|
||||||
|
// single-quoted. No cmd shim: cmd re-parses %VAR% and would re-introduce the problem.
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Carries the free-text initial prompt to PowerShell out-of-band (never on the
|
||||||
|
// command line) so wt.exe cannot split it on ';'.
|
||||||
|
private const string PromptEnvVar = "CLAUDEDO_LAUNCH_PROMPT";
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Arg order: --allowedTools is variadic (space-separated). The positional prompt
|
||||||
|
// must follow a single-value flag, or it will be swallowed —
|
||||||
|
// --append-system-prompt-file serves as that buffer.
|
||||||
|
var command = BuildPwshCommand(resolvedClaude, new[]
|
||||||
|
{
|
||||||
|
"--model", Model,
|
||||||
|
"--permission-mode", "plan",
|
||||||
|
"--allowedTools", AllowedTools,
|
||||||
|
"--append-system-prompt-file", ctx.Files.SystemPromptPath,
|
||||||
|
}, appendPrompt: true);
|
||||||
|
|
||||||
|
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
||||||
|
{
|
||||||
|
env["MAX_THINKING_TOKENS"] = "20000";
|
||||||
|
env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||||
|
env[PromptEnvVar] = File.ReadAllText(ctx.Files.InitialPromptPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LaunchInteractiveAsync(InteractiveLaunchContext 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[]
|
||||||
|
{
|
||||||
|
"--model", Model,
|
||||||
|
"--permission-mode", "auto",
|
||||||
|
}, appendPrompt: true);
|
||||||
|
|
||||||
|
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
||||||
|
{
|
||||||
|
env["MAX_THINKING_TOKENS"] = "20000";
|
||||||
|
env[PromptEnvVar] = ctx.InitialPrompt;
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
}, appendPrompt: false);
|
||||||
|
|
||||||
|
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
||||||
|
{
|
||||||
|
env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (controlled)
|
||||||
|
// arguments, optionally appending the free-text prompt from $env:CLAUDEDO_LAUNCH_PROMPT.
|
||||||
|
// The prompt is referenced as a variable so PowerShell binds its value as ONE argument
|
||||||
|
// (never re-tokenized). The `-replace '"','\"'` works around Windows PowerShell 5.1's
|
||||||
|
// native-argument quirk, which otherwise strips embedded double-quotes before the child
|
||||||
|
// sees them; all other characters (';', '&', spaces, backslashes, newlines) pass through.
|
||||||
|
private static string BuildPwshCommand(string claudePath, IReadOnlyList<string> args, bool appendPrompt)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("& ").Append(PwshQuote(claudePath));
|
||||||
|
foreach (var a in args)
|
||||||
|
sb.Append(' ').Append(PwshQuote(a));
|
||||||
|
if (appendPrompt)
|
||||||
|
sb.Append(" ($env:").Append(PromptEnvVar).Append(" -replace '\"','\\\"')");
|
||||||
|
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<IDictionary<string, string?>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
// 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>
|
|
||||||
// Launch model: wt.exe directly spawns claude.exe via argv (UseShellExecute=false).
|
|
||||||
// No cmd /k shim — arbitrary initial-prompt content would be re-parsed by cmd.exe otherwise.
|
|
||||||
|
|
||||||
using System.Diagnostics;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
|
|
||||||
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 = ModelRegistry.PlanningAlias;
|
|
||||||
|
|
||||||
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.SystemPromptPath))
|
|
||||||
throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}");
|
|
||||||
if (!File.Exists(ctx.Files.InitialPromptPath))
|
|
||||||
throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}");
|
|
||||||
|
|
||||||
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 = false,
|
|
||||||
CreateNoWindow = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
|
|
||||||
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
|
||||||
|
|
||||||
// Arg order: --allowedTools is variadic (space-separated). The positional
|
|
||||||
// prompt must follow a single-value flag, or it will be swallowed.
|
|
||||||
// --append-system-prompt-file serves as that buffer.
|
|
||||||
psi.ArgumentList.Add("-d");
|
|
||||||
psi.ArgumentList.Add(ctx.WorkingDir);
|
|
||||||
psi.ArgumentList.Add(resolvedClaude);
|
|
||||||
psi.ArgumentList.Add("--model");
|
|
||||||
psi.ArgumentList.Add(Model);
|
|
||||||
psi.ArgumentList.Add("--permission-mode");
|
|
||||||
psi.ArgumentList.Add("plan");
|
|
||||||
psi.ArgumentList.Add("--allowedTools");
|
|
||||||
psi.ArgumentList.Add(AllowedTools);
|
|
||||||
psi.ArgumentList.Add("--append-system-prompt-file");
|
|
||||||
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
|
|
||||||
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
|
|
||||||
|
|
||||||
var proc = Process.Start(psi)
|
|
||||||
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(ctx.WorkingDir))
|
|
||||||
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
|
||||||
|
|
||||||
var resolvedWt = Resolve(_wtPath)
|
|
||||||
?? throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
|
||||||
var resolvedClaude = Resolve(_claudePath)
|
|
||||||
?? throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = resolvedWt,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
|
|
||||||
|
|
||||||
psi.ArgumentList.Add("-d");
|
|
||||||
psi.ArgumentList.Add(ctx.WorkingDir);
|
|
||||||
psi.ArgumentList.Add(resolvedClaude);
|
|
||||||
psi.ArgumentList.Add("--model");
|
|
||||||
psi.ArgumentList.Add(Model);
|
|
||||||
psi.ArgumentList.Add("--permission-mode");
|
|
||||||
psi.ArgumentList.Add("auto");
|
|
||||||
psi.ArgumentList.Add(ctx.InitialPrompt);
|
|
||||||
|
|
||||||
var proc = Process.Start(psi)
|
|
||||||
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
|
||||||
|
|
||||||
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}");
|
|
||||||
|
|
||||||
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 = false,
|
|
||||||
CreateNoWindow = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
|
||||||
|
|
||||||
psi.ArgumentList.Add("-d");
|
|
||||||
psi.ArgumentList.Add(ctx.WorkingDir);
|
|
||||||
psi.ArgumentList.Add(resolvedClaude);
|
|
||||||
psi.ArgumentList.Add("--permission-mode");
|
|
||||||
psi.ArgumentList.Add("plan");
|
|
||||||
psi.ArgumentList.Add("--resume");
|
|
||||||
psi.ArgumentList.Add(ctx.ClaudeSessionId);
|
|
||||||
|
|
||||||
var proc = Process.Start(psi)
|
|
||||||
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -147,8 +147,8 @@ builder.Services.AddHostedService(sp => new PlanningLineageRecovery(
|
|||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
planningSessionsDir,
|
planningSessionsDir,
|
||||||
sp.GetRequiredService<ILogger<PlanningLineageRecovery>>()));
|
sp.GetRequiredService<ILogger<PlanningLineageRecovery>>()));
|
||||||
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
builder.Services.AddSingleton<ITerminalLauncher>(sp =>
|
||||||
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
new WindowsTerminalLauncher("wt.exe", cfg.ClaudeBin));
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
||||||
builder.Services.AddScoped<TaskRunMcpContextAccessor>();
|
builder.Services.AddScoped<TaskRunMcpContextAccessor>();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class PlanningHubTests : IDisposable
|
|||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly string _rootDir;
|
private readonly string _rootDir;
|
||||||
private readonly PlanningSessionManager _planning;
|
private readonly PlanningSessionManager _planning;
|
||||||
private readonly FakePlanningLauncher _launcher;
|
private readonly FakeTerminalLauncher _launcher;
|
||||||
private readonly RecordingClientProxy _proxy;
|
private readonly RecordingClientProxy _proxy;
|
||||||
|
|
||||||
public PlanningHubTests()
|
public PlanningHubTests()
|
||||||
@@ -40,7 +40,7 @@ public sealed class PlanningHubTests : IDisposable
|
|||||||
var built = TaskStateServiceBuilder.Build(_db.CreateFactory());
|
var built = TaskStateServiceBuilder.Build(_db.CreateFactory());
|
||||||
_planning = new PlanningSessionManager(
|
_planning = new PlanningSessionManager(
|
||||||
_tasks, _lists, settingsRepo, git, cfg, _rootDir, built.State, built.Chain);
|
_tasks, _lists, settingsRepo, git, cfg, _rootDir, built.State, built.Chain);
|
||||||
_launcher = new FakePlanningLauncher();
|
_launcher = new FakeTerminalLauncher();
|
||||||
_proxy = new RecordingClientProxy();
|
_proxy = new RecordingClientProxy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ public sealed class PlanningHubTests : IDisposable
|
|||||||
_launcher.ShouldThrow = true;
|
_launcher.ShouldThrow = true;
|
||||||
var hub = CreateHub();
|
var hub = CreateHub();
|
||||||
|
|
||||||
await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
await Assert.ThrowsAsync<TerminalLaunchException>(() =>
|
||||||
hub.StartPlanningSessionAsync(taskId));
|
hub.StartPlanningSessionAsync(taskId));
|
||||||
|
|
||||||
var loaded = await _tasks.GetByIdAsync(taskId);
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||||
@@ -173,21 +173,21 @@ public sealed class PlanningHubTests : IDisposable
|
|||||||
// Fakes
|
// Fakes
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
|
internal sealed class FakeTerminalLauncher : ITerminalLauncher
|
||||||
{
|
{
|
||||||
public bool ShouldThrow { get; set; }
|
public bool ShouldThrow { get; set; }
|
||||||
public int LaunchStartCalls { get; private set; }
|
public int LaunchStartCalls { get; private set; }
|
||||||
public int LaunchResumeCalls { get; private set; }
|
public int LaunchResumeCalls { get; private set; }
|
||||||
public int LaunchInteractiveCalls { get; private set; }
|
public int LaunchInteractiveCalls { get; private set; }
|
||||||
|
|
||||||
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
public Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
|
||||||
LaunchStartCalls++;
|
LaunchStartCalls++;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
public Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
LaunchResumeCalls++;
|
LaunchResumeCalls++;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -195,7 +195,7 @@ internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
|
|||||||
|
|
||||||
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
|
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
|
||||||
LaunchInteractiveCalls++;
|
LaunchInteractiveCalls++;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using ClaudeDo.Worker.Planning;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.Tests.Planning;
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
public sealed class WindowsTerminalPlanningLauncherTests
|
public sealed class WindowsTerminalLauncherTests
|
||||||
{
|
{
|
||||||
private static PlanningSessionStartContext MakeStartCtx(string? wd = null)
|
private static PlanningSessionStartContext MakeStartCtx(string? wd = null)
|
||||||
{
|
{
|
||||||
@@ -22,27 +22,27 @@ public sealed class WindowsTerminalPlanningLauncherTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LaunchStartAsync_WorkingDirMissing_Throws()
|
public async Task LaunchPlanningStartAsync_WorkingDirMissing_Throws()
|
||||||
{
|
{
|
||||||
var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid()));
|
var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid()));
|
||||||
var sut = new WindowsTerminalPlanningLauncher(wtPath: "wt", claudePath: "claude");
|
var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude");
|
||||||
var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
|
||||||
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
sut.LaunchPlanningStartAsync(ctx, CancellationToken.None));
|
||||||
Assert.Contains("Working directory", ex.Message);
|
Assert.Contains("Working directory", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LaunchStartAsync_WtMissing_Throws()
|
public async Task LaunchPlanningStartAsync_WtMissing_Throws()
|
||||||
{
|
{
|
||||||
var ctx = MakeStartCtx();
|
var ctx = MakeStartCtx();
|
||||||
File.WriteAllText(ctx.Files.SystemPromptPath, "sp");
|
File.WriteAllText(ctx.Files.SystemPromptPath, "sp");
|
||||||
File.WriteAllText(ctx.Files.InitialPromptPath, "ip");
|
File.WriteAllText(ctx.Files.InitialPromptPath, "ip");
|
||||||
|
|
||||||
var sut = new WindowsTerminalPlanningLauncher(
|
var sut = new WindowsTerminalLauncher(
|
||||||
wtPath: "C:/no/such/wt.exe",
|
wtPath: "C:/no/such/wt.exe",
|
||||||
claudePath: "claude");
|
claudePath: "claude");
|
||||||
var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
|
||||||
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
sut.LaunchPlanningStartAsync(ctx, CancellationToken.None));
|
||||||
Assert.Contains("Windows Terminal", ex.Message);
|
Assert.Contains("Windows Terminal", ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user