198 lines
8.2 KiB
C#
198 lines
8.2 KiB
C#
// 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)
|
|
// Extra read roots: --add-dir <dir...> (variadic)
|
|
// 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 (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/<taskId> — 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. 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;
|
|
|
|
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, new[]
|
|
{
|
|
"--permission-mode", "plan",
|
|
"--resume", ctx.ClaudeSessionId,
|
|
});
|
|
|
|
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
|
{
|
|
env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
|
});
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken)
|
|
{
|
|
if (!Directory.Exists(workingDir))
|
|
throw new TerminalLaunchException($"Working directory does not exist: {workingDir}");
|
|
|
|
var resolvedWt = ResolveWtOrThrow();
|
|
var resolvedClaude = ResolveClaudeOrThrow();
|
|
|
|
var command = BuildResumeCommand(resolvedClaude, claudeSessionId);
|
|
|
|
StartInWindowsTerminal(resolvedWt, workingDir, command, static _ => { });
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// Resumes a session by id in default (interactive) permission mode: the user drives
|
|
// tool approvals in the terminal, unlike planning which pins --permission-mode plan.
|
|
internal static string BuildResumeCommand(string claudePath, string claudeSessionId) =>
|
|
BuildPwshCommand(claudePath, 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)
|
|
{
|
|
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 BuildPwshCommand(claudePath, new[]
|
|
{
|
|
"--model", Model,
|
|
"--permission-mode", "plan",
|
|
"--allowedTools", AllowedTools,
|
|
"--add-dir", ctx.Files.SessionDirectory,
|
|
"--append-system-prompt-file", ctx.Files.SystemPromptPath,
|
|
kickoff,
|
|
});
|
|
}
|
|
|
|
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<string> 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<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;
|
|
}
|
|
}
|