From 865e12c0de706614d4345323ae8b0c9eef9047a1 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 1 Jul 2026 11:55:12 +0200 Subject: [PATCH] fix(worker): seed planning brief via file to avoid newline truncation --- .../Planning/WindowsTerminalLauncher.cs | 70 +++++++++++-------- .../Planning/WindowsTerminalLauncherTests.cs | 24 +++++++ 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs index a1628694..40dd36cd 100644 --- a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs @@ -2,15 +2,21 @@ // 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 (file form) +// Extra read roots: --add-dir (variadic) // Session ID: no pre-assign flag; resume with --resume // 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. +// 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/ — 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; @@ -27,10 +33,6 @@ 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; @@ -53,22 +55,12 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher 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); + var command = BuildPlanningStartCommand(resolvedClaude, ctx); 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; @@ -86,7 +78,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher { "--permission-mode", "plan", "--resume", ctx.ClaudeSessionId, - }, appendPrompt: false); + }); StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env => { @@ -96,26 +88,42 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher return Task.CompletedTask; } + // 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 (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 args, bool appendPrompt) + // 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 args) { 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(); } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs index f45038cf..6ae264fc 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs @@ -45,4 +45,28 @@ public sealed class WindowsTerminalLauncherTests sut.LaunchPlanningStartAsync(ctx, CancellationToken.None)); Assert.Contains("Windows Terminal", ex.Message); } + + [Fact] + public void BuildPlanningStartCommand_SeedsViaBriefFile_NotInlineText() + { + var ctx = MakeStartCtx(); + + var command = WindowsTerminalLauncher.BuildPlanningStartCommand("claude.exe", ctx); + + // The brief is read from a file: the kickoff references the brief path, and its + // directory is exposed via --add-dir so the Read tool can open it. + Assert.Contains(ctx.Files.InitialPromptPath, command); + Assert.Contains($"--add-dir' '{ctx.Files.SessionDirectory}", command); + + // The positional kickoff must follow the single-value --append-system-prompt-file + // flag, or a preceding variadic flag would swallow it. + var appendIdx = command.IndexOf("--append-system-prompt-file", StringComparison.Ordinal); + var addDirIdx = command.IndexOf("--add-dir", StringComparison.Ordinal); + Assert.True(appendIdx > addDirIdx, "--append-system-prompt-file must come after --add-dir"); + Assert.True(command.IndexOf(ctx.Files.InitialPromptPath, StringComparison.Ordinal) > appendIdx, + "the kickoff prompt must be the last (positional) token"); + + // The legacy env-var indirection is gone. + Assert.DoesNotContain("CLAUDEDO_LAUNCH_PROMPT", command); + } }