Files
ClaudeDo/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
T
mika kuns 624ec7a668 fix(planning): use default permission mode so MCP planning tools don't prompt
Interactive planning sessions launched with --permission-mode plan, which
gates EVERY MCP tool call regardless of --allowedTools (verified: even a
read-only mcp__claudedo__list_task_lists is denied under plan mode). So the
session prompted the user on the first CreateChildTask -- the whole point of
a planning session.

Switch BuildPlanningStartArgs/BuildPlanningResumeArgs to --permission-mode
default, which honours the allowlist. File edits stay blocked via the planning
system prompt + AllowedTools omitting Write/Edit/Bash. Resume also re-passes
--allowedTools, since the CLI does not restore it across --resume.

The earlier 'glob does not match' hypothesis was empirically falsified:
mcp__claudedo__*, the bare server name, and the explicit tool name all allow
the tool with zero permission_denials in default mode.
2026-07-24 12:38:45 +02:00

220 lines
10 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, BuildPlanningResumeArgs(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 with no extra flags: the user drives every tool approval in the
// terminal, unlike planning which additionally allowlists its MCP planning tools.
internal static string BuildResumeCommand(string claudePath, string claudeSessionId) =>
BuildPwshCommand(claudePath, BuildResumeArgs(claudeSessionId));
// The raw claude CLI args for a --resume launch, shared with InteractiveLaunchSpecService
// (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line).
internal static IReadOnlyList<string> BuildResumeArgs(string claudeSessionId) =>
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) =>
BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx));
// The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY
// planning path (InteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than
// a pwsh-wrapped command line. 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 IReadOnlyList<string> BuildPlanningStartArgs(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 new[]
{
"--model", Model,
// NOT --permission-mode plan: plan mode gates EVERY MCP tool call regardless of
// --allowedTools, so the session would prompt the user on the first CreateChildTask
// (the whole point of a planning session). Default mode honours the allowlist below,
// and file edits stay blocked anyway — the planning system prompt forbids them and
// AllowedTools omits Write/Edit/Bash (an unexpected edit would prompt, not run silently).
"--permission-mode", "default",
"--allowedTools", AllowedTools,
"--add-dir", ctx.Files.SessionDirectory,
"--append-system-prompt-file", ctx.Files.SystemPromptPath,
kickoff,
};
}
// The raw claude CLI args for an interactive planning RESUME, shared with the embedded-ConPTY
// planning path. Re-passes the planning allowlist in default mode (not --resume alone): the CLI
// does not restore --allowedTools across a resume, and plan mode would gate the MCP tools.
internal static IReadOnlyList<string> BuildPlanningResumeArgs(string claudeSessionId) =>
new[] { "--permission-mode", "default", "--allowedTools", AllowedTools, "--resume", claudeSessionId };
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.");
}
// Not private: reused by InteractiveLaunchSpecService to resolve the claude executable
// for a ConPTY-hosted launch spec the same way a wt-launched terminal resolves it.
internal 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;
}
}