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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user