fix(worker): seed planning brief via file to avoid newline truncation
This commit is contained in:
@@ -2,15 +2,21 @@
|
|||||||
// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists)
|
// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists)
|
||||||
// Allowed-tools: --allowedTools (camelCase), comma-separated tokens
|
// Allowed-tools: --allowedTools (camelCase), comma-separated tokens
|
||||||
// System prompt: --append-system-prompt-file <path> (file form)
|
// 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>
|
// Session ID: no pre-assign flag; resume with --resume <id>
|
||||||
// Launch model: wt.exe -> powershell -> claude.exe (UseShellExecute=false).
|
// Launch model: wt.exe -> powershell -> claude.exe (UseShellExecute=false).
|
||||||
// wt.exe treats ';' as a tab/command delimiter in EVERY argument, regardless of
|
// 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
|
// quoting (microsoft/terminal#13264), so nothing containing ';' may appear on the wt
|
||||||
// free-text prompt must never appear on the wt command line. We hand it to PowerShell
|
// command line. Every token we place there is a controlled constant or a filesystem
|
||||||
// out-of-band via an environment variable and reference it as $env:VAR — PowerShell
|
// path under ~/.todo-app/sessions/<taskId> — none can contain ';' — and each is
|
||||||
// binds a variable's value as a single argument without re-tokenizing it, so the prompt
|
// single-quoted for PowerShell.
|
||||||
// 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.
|
// 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.Diagnostics;
|
||||||
using System.Text;
|
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 AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
|
||||||
private const string Model = ModelRegistry.PlanningAlias;
|
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 _wtPath;
|
||||||
private readonly string _claudePath;
|
private readonly string _claudePath;
|
||||||
|
|
||||||
@@ -53,22 +55,12 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
|||||||
var resolvedWt = ResolveWtOrThrow();
|
var resolvedWt = ResolveWtOrThrow();
|
||||||
var resolvedClaude = ResolveClaudeOrThrow();
|
var resolvedClaude = ResolveClaudeOrThrow();
|
||||||
|
|
||||||
// Arg order: --allowedTools is variadic (space-separated). The positional prompt
|
var command = BuildPlanningStartCommand(resolvedClaude, ctx);
|
||||||
// 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 =>
|
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
||||||
{
|
{
|
||||||
env["MAX_THINKING_TOKENS"] = "20000";
|
env["MAX_THINKING_TOKENS"] = "20000";
|
||||||
env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
env["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||||
env[PromptEnvVar] = File.ReadAllText(ctx.Files.InitialPromptPath);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -86,7 +78,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
|||||||
{
|
{
|
||||||
"--permission-mode", "plan",
|
"--permission-mode", "plan",
|
||||||
"--resume", ctx.ClaudeSessionId,
|
"--resume", ctx.ClaudeSessionId,
|
||||||
}, appendPrompt: false);
|
});
|
||||||
|
|
||||||
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
|
||||||
{
|
{
|
||||||
@@ -96,26 +88,42 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
|||||||
return Task.CompletedTask;
|
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() =>
|
private string ResolveWtOrThrow() =>
|
||||||
Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}");
|
Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||||
|
|
||||||
private string ResolveClaudeOrThrow() =>
|
private string ResolveClaudeOrThrow() =>
|
||||||
Resolve(_claudePath) ?? throw new TerminalLaunchException($"claude executable not found: {_claudePath}");
|
Resolve(_claudePath) ?? throw new TerminalLaunchException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
// Builds the PowerShell command that invokes claude with the given (controlled)
|
// Builds the PowerShell command that invokes claude with the given tokens. Each token
|
||||||
// arguments, optionally appending the free-text prompt from $env:CLAUDEDO_LAUNCH_PROMPT.
|
// is single-quoted, so ';', '&', spaces, quotes, and backslashes pass through to the
|
||||||
// The prompt is referenced as a variable so PowerShell binds its value as ONE argument
|
// child process untouched. No cmd shim: cmd would re-parse the arguments.
|
||||||
// (never re-tokenized). The `-replace '"','\"'` works around Windows PowerShell 5.1's
|
private static string BuildPwshCommand(string claudePath, IReadOnlyList<string> args)
|
||||||
// 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();
|
var sb = new StringBuilder();
|
||||||
sb.Append("& ").Append(PwshQuote(claudePath));
|
sb.Append("& ").Append(PwshQuote(claudePath));
|
||||||
foreach (var a in args)
|
foreach (var a in args)
|
||||||
sb.Append(' ').Append(PwshQuote(a));
|
sb.Append(' ').Append(PwshQuote(a));
|
||||||
if (appendPrompt)
|
|
||||||
sb.Append(" ($env:").Append(PromptEnvVar).Append(" -replace '\"','\\\"')");
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,4 +45,28 @@ public sealed class WindowsTerminalLauncherTests
|
|||||||
sut.LaunchPlanningStartAsync(ctx, CancellationToken.None));
|
sut.LaunchPlanningStartAsync(ctx, CancellationToken.None));
|
||||||
Assert.Contains("Windows Terminal", ex.Message);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user