fix(worker): keep interactive & planning prompts intact past Windows Terminal

wt.exe treats ';' as a command/tab delimiter in every argument, with no escape
that survives quoting (microsoft/terminal#13264), so a task description
containing ';' spawned extra terminals on "Run interactively" and planning start.
Route the launch as wt -> powershell -> claude and pass the free-text prompt via
$env:CLAUDEDO_LAUNCH_PROMPT so it never reaches the wt command line; PowerShell
binds the variable as a single argument (embedded quotes escaped for PS 5.1).

Also clarify the launcher, which serves interactive runs too (not just planning):
IPlanningTerminalLauncher -> ITerminalLauncher, WindowsTerminalPlanningLauncher ->
WindowsTerminalLauncher, LaunchStart/Resume -> LaunchPlanning{Start,Resume}Async.
This commit is contained in:
Mika Kuns
2026-06-24 13:19:03 +02:00
parent f86b78593e
commit ea16da2756
9 changed files with 233 additions and 206 deletions

View File

@@ -24,7 +24,7 @@ public sealed class PlanningHubTests : IDisposable
private readonly ListRepository _lists;
private readonly string _rootDir;
private readonly PlanningSessionManager _planning;
private readonly FakePlanningLauncher _launcher;
private readonly FakeTerminalLauncher _launcher;
private readonly RecordingClientProxy _proxy;
public PlanningHubTests()
@@ -40,7 +40,7 @@ public sealed class PlanningHubTests : IDisposable
var built = TaskStateServiceBuilder.Build(_db.CreateFactory());
_planning = new PlanningSessionManager(
_tasks, _lists, settingsRepo, git, cfg, _rootDir, built.State, built.Chain);
_launcher = new FakePlanningLauncher();
_launcher = new FakeTerminalLauncher();
_proxy = new RecordingClientProxy();
}
@@ -110,7 +110,7 @@ public sealed class PlanningHubTests : IDisposable
_launcher.ShouldThrow = true;
var hub = CreateHub();
await Assert.ThrowsAsync<PlanningLaunchException>(() =>
await Assert.ThrowsAsync<TerminalLaunchException>(() =>
hub.StartPlanningSessionAsync(taskId));
var loaded = await _tasks.GetByIdAsync(taskId);
@@ -173,21 +173,21 @@ public sealed class PlanningHubTests : IDisposable
// Fakes
// ---------------------------------------------------------------------------
internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
internal sealed class FakeTerminalLauncher : ITerminalLauncher
{
public bool ShouldThrow { get; set; }
public int LaunchStartCalls { get; private set; }
public int LaunchResumeCalls { get; private set; }
public int LaunchInteractiveCalls { get; private set; }
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
public Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
{
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
LaunchStartCalls++;
return Task.CompletedTask;
}
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
public Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
{
LaunchResumeCalls++;
return Task.CompletedTask;
@@ -195,7 +195,7 @@ internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
{
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
LaunchInteractiveCalls++;
return Task.CompletedTask;
}