feat(planning): run interactive planning sessions via embedded ConPTY

Planning start/resume opened an external Windows Terminal (wt) window.
Route them through the embedded ConPTY Command Center pane instead, matching
the existing interactive-session UX (no external window).

- Extract bare planning arg builders (BuildPlanningStartArgs/ResumeArgs) from
  WindowsTerminalLauncher; the wt path still uses them (kept, not removed).
- InteractiveLaunchSpecService.BuildPlanningStart/Resume map a planning
  context into a LaunchSpec (planning args + env: MAX_THINKING_TOKENS,
  CLAUDEDO_PLANNING_TOKEN). Hub GetPlanningStart/ResumeLaunchSpec run
  StartAsync/ResumeAsync then return the spec.
- UI: OpenPlanningSession + the resume branch raise OpenPlanningConPtyRequested;
  the shell opens Mission Control and hosts a planning ConPTY pane.

Env is process-global by design (sequential human-paced sessions). wt planning
code retained. Tests added for the arg/env mapping.
This commit is contained in:
mika kuns
2026-07-24 12:12:23 +02:00
parent 2612831a5e
commit ef285b21fd
16 changed files with 249 additions and 24 deletions
@@ -99,6 +99,10 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0));
@@ -70,6 +70,31 @@ public sealed class WindowsTerminalLauncherTests
Assert.DoesNotContain("CLAUDEDO_LAUNCH_PROMPT", command);
}
[Fact]
public void BuildPlanningStartArgs_HasPlanningFlagsAndKickoffLast()
{
var ctx = MakeStartCtx();
var args = WindowsTerminalLauncher.BuildPlanningStartArgs(ctx);
Assert.Equal("--model", args[0]);
var permIdx = args.ToList().IndexOf("--permission-mode");
Assert.True(permIdx >= 0);
Assert.Equal("plan", args[permIdx + 1]);
Assert.Contains("--allowedTools", args);
Assert.Contains(ctx.Files.SessionDirectory, args);
Assert.Contains(ctx.Files.SystemPromptPath, args);
// Kickoff positional is the last token and points at the brief file.
Assert.Contains(ctx.Files.InitialPromptPath, args[^1]);
}
[Fact]
public void BuildPlanningResumeArgs_PinsPlanModeAndResume()
{
var args = WindowsTerminalLauncher.BuildPlanningResumeArgs("sess-9");
Assert.Equal(new[] { "--permission-mode", "plan", "--resume", "sess-9" }, args);
}
[Fact]
public void BuildResumeCommand_ResumesSessionSingleQuoted()
{
@@ -2,6 +2,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -342,4 +343,40 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
}
[Fact]
public void BuildPlanningStart_MapsPlanningArgsAndEnv()
{
var sessionDir = Path.Combine(_tempDir, "sess");
Directory.CreateDirectory(sessionDir);
var ctx = new PlanningSessionStartContext(
ParentTaskId: "p1", WorkingDir: _worktreeDir, Token: "tok-1",
WorktreePath: _worktreeDir, BranchName: "claudedo/planning/p1",
Files: new PlanningSessionFiles(sessionDir,
Path.Combine(sessionDir, "system-prompt.md"),
Path.Combine(sessionDir, "initial-prompt.txt")));
var spec = BuildService().BuildPlanningStart(ctx);
Assert.Equal(_worktreeDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
Assert.Contains("--permission-mode", spec.Args);
Assert.Contains("plan", spec.Args);
Assert.Equal("tok-1", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
Assert.Equal("20000", spec.Env["MAX_THINKING_TOKENS"]);
}
[Fact]
public void BuildPlanningResume_MapsResumeArgsAndToken()
{
var ctx = new PlanningSessionResumeContext(
ParentTaskId: "p1", WorkingDir: _worktreeDir,
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
var spec = BuildService().BuildPlanningResume(ctx);
Assert.Equal(new[] { "--permission-mode", "plan", "--resume", "sess-42" }, spec.Args);
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
Assert.Equal(_worktreeDir, spec.Cwd);
}
}
@@ -76,6 +76,12 @@ sealed class FakeWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public int PlanningStartSpecCalls { get; private set; }
public int PlanningResumeSpecCalls { get; private set; }
public Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
{ PlanningStartSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
public Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
{ PlanningResumeSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
public Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
@@ -224,25 +230,29 @@ public class TasksIslandViewModelPlanningTests
}
[Fact]
public async Task OpenPlanningSession_IgnoresNonIdleRow()
public void OpenPlanningSession_IgnoresNonIdleRow()
{
var row = MakeRow("t1", TaskStatus.Queued);
var (vm, worker) = VmFactory.Create([row]);
var (vm, _) = VmFactory.Create([row]);
(string TaskId, bool Resume)? raised = null;
vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume);
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
vm.OpenPlanningSessionCommand.Execute(row);
Assert.Equal(0, worker.StartPlanningCalls);
Assert.Null(raised);
}
[Fact]
public async Task OpenPlanningSession_CallsWorkerForIdleRow()
public void OpenPlanningSession_RequestsConPtyPaneForIdleRow()
{
var row = MakeRow("t1", TaskStatus.Idle);
var (vm, worker) = VmFactory.Create([row]);
var (vm, _) = VmFactory.Create([row]);
(string TaskId, bool Resume)? raised = null;
vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume);
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
vm.OpenPlanningSessionCommand.Execute(row);
Assert.Equal(1, worker.StartPlanningCalls);
Assert.Equal(("t1", false), raised);
}
[Fact]