fix(worker): transport ConPTY task brief via file, not CLI argument
BuildForTaskAsync's fresh-session path flattened the task title+description into one positional CLI argument, which the ConPTY host joins into a single command line and claude re-splits on whitespace -- any dash-leading token in the description (e.g. "->", "--abort") was misread as an unknown option, and a raw multi-line prompt truncated at its first newline regardless. Now the brief is written to ~/.todo-app/task-sessions/<taskId>/brief.md and exposed via --add-dir, with a single-line kickoff pointing claude at it -- the same pattern BuildForMergeHelperAsync and the planning launcher already use.
This commit is contained in:
@@ -94,17 +94,17 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||
|
||||
// Resume an existing session as-is; for a fresh session, seed the interactive TUI with
|
||||
// the task's prompt (title + description) as claude's positional prompt so it starts on
|
||||
// the task -- the user then supervises/answers rather than retyping it.
|
||||
var args = run?.SessionId is { Length: > 0 } sessionId
|
||||
? WindowsTerminalLauncher.BuildResumeArgs(sessionId)
|
||||
: BuildFreshPromptArgs(task);
|
||||
|
||||
// Start the hand-driven session at the effort configured for the model this task would run
|
||||
// under, instead of inheriting whatever the user's global Claude Code config happens to be.
|
||||
// The model itself is deliberately NOT forced here — the user can still switch it in the TUI.
|
||||
args = WithEffort(args, EffortFor(globalSettings, task.Model ?? listConfig?.Model));
|
||||
var effort = EffortFor(globalSettings, task.Model ?? listConfig?.Model);
|
||||
|
||||
// Resume an existing session as-is; for a fresh session, seed the interactive TUI with
|
||||
// the task's brief (title + description) via a file, never as a positional CLI argument --
|
||||
// see BuildFreshTaskArgsAsync for why.
|
||||
var args = run?.SessionId is { Length: > 0 } sessionId
|
||||
? WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), effort)
|
||||
: await BuildFreshTaskArgsAsync(task, effort, ct);
|
||||
|
||||
// Same run environment variable ClaudeProcess sets for every headless run: the
|
||||
// AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for
|
||||
@@ -324,20 +324,47 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
return new AppSettingsRepository(ctx).GetAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
// The positional prompt claude opens the interactive session on. Empty (no positional arg)
|
||||
// if the task has neither a title nor a description.
|
||||
private static IReadOnlyList<string> BuildFreshPromptArgs(TaskEntity task)
|
||||
// The task's brief (title + description, unmodified/multi-line) must never travel as a CLI
|
||||
// argument: the ConPTY host flattens Args into one command line to spawn the process, and
|
||||
// claude re-splits that line on whitespace, so any token in the description that starts with
|
||||
// '-' (e.g. "->", "--abort") gets misread as an unknown option, and a newline would truncate
|
||||
// the brief at its first line even if it didn't. So it's written to a file instead --
|
||||
// same pattern as BuildForMergeHelperAsync / WindowsTerminalLauncher.BuildPlanningStartArgs --
|
||||
// and claude is pointed at it with a single-line kickoff. --add-dir exposes the session dir to
|
||||
// Read; --effort (single-value) must sit directly before the positional kickoff so the
|
||||
// preceding variadic --add-dir doesn't swallow the kickoff as another directory.
|
||||
// No brief (task has neither a title nor a description) -> no positional arg at all.
|
||||
private static async Task<IReadOnlyList<string>> BuildFreshTaskArgsAsync(TaskEntity task, string effort, CancellationToken ct)
|
||||
{
|
||||
var brief = BuildTaskBrief(task);
|
||||
if (string.IsNullOrEmpty(brief))
|
||||
return new[] { "--effort", effort };
|
||||
|
||||
var sessionDir = Path.Combine(Paths.AppDataRoot(), "task-sessions", task.Id);
|
||||
Directory.CreateDirectory(sessionDir);
|
||||
var briefPath = Path.Combine(sessionDir, "brief.md");
|
||||
await File.WriteAllTextAsync(briefPath, brief, ct);
|
||||
|
||||
return new[]
|
||||
{
|
||||
"--add-dir", sessionDir,
|
||||
"--effort", effort,
|
||||
$"Read the file {briefPath} first. It contains the task you must work on. " +
|
||||
"After reading it, begin the session as your instructions describe.",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTaskBrief(TaskEntity task)
|
||||
{
|
||||
var title = task.Title?.Trim();
|
||||
var description = task.Description?.Trim();
|
||||
var prompt = (string.IsNullOrEmpty(title), string.IsNullOrEmpty(description)) switch
|
||||
return (string.IsNullOrEmpty(title), string.IsNullOrEmpty(description)) switch
|
||||
{
|
||||
(false, false) => $"{title}\n\n{description}",
|
||||
(false, true) => title!,
|
||||
(true, false) => description!,
|
||||
_ => string.Empty,
|
||||
};
|
||||
return string.IsNullOrEmpty(prompt) ? Array.Empty<string>() : new[] { prompt };
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(IReadOnlyList<string> requested, CancellationToken ct)
|
||||
|
||||
Reference in New Issue
Block a user