diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 18e5bb40..8753be16 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -266,6 +266,7 @@
"overviewMode": "Übersicht",
"closeSession": "Sitzung schließen",
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
+ "planningTitleSuffix": " (Planung)",
"question": {
"title": "Claude fragt nach",
"placeholder": "Antwort eingeben…",
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index 0938797f..9a82ee24 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -266,6 +266,7 @@
"overviewMode": "Overview",
"closeSession": "Close session",
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
+ "planningTitleSuffix": " (Planning)",
"question": {
"title": "Claude is asking",
"placeholder": "Type your answer…",
diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
index 64050c2a..b0e32e93 100644
--- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
@@ -82,6 +82,11 @@ public interface IWorkerClient : INotifyPropertyChanged
/// Launch spec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree.
Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
+ /// Starts a planning session and returns the launch spec for an embedded ConPTY
+ /// planning terminal (replaces StartPlanningSessionAsync's external wt window).
+ Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
+ /// Resumes a planning session and returns its embedded-ConPTY launch spec.
+ Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default);
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs
index acccd4c1..56768cc4 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -519,6 +519,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await _hub.InvokeAsync("GetAdHocLaunchSpec", directory, ct);
+ public async Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
+ => await _hub.InvokeAsync("GetPlanningStartLaunchSpec", taskId, ct);
+
+ public async Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
+ => await _hub.InvokeAsync("GetPlanningResumeLaunchSpec", taskId, ct);
+
public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
index 0d8a6b3a..3c600eed 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
@@ -813,19 +813,24 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
- private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
+ private void OpenPlanningSession(TaskRowViewModel? row)
{
if (row is null) return;
if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return;
- ForegroundHelper.AllowAny();
- try { await _worker!.StartPlanningSessionAsync(row.Id); }
- catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningOpenFailed", ex.Message)); }
+ // Planning now runs as an embedded ConPTY pane in the Command Center (not an external wt
+ // window). The shell owns Mission Control, so raise an event; the actual StartAsync happens
+ // server-side inside GetPlanningStartLaunchSpec when the pane opens.
+ OpenPlanningConPtyRequested?.Invoke(row.Id, false);
}
// Opens the task in an embedded ConPTY terminal pane in the Command Center. The shell owns
// the Mission Control view model, so this just raises an event for it to act on.
public event Action? OpenConPtySessionRequested;
+ // Opens (resume=false) or resumes (resume=true) a planning session as an embedded ConPTY
+ // pane in the Command Center.
+ public event Action? OpenPlanningConPtyRequested;
+
[RelayCommand]
private void OpenConPtySession(TaskRowViewModel? row)
{
@@ -865,8 +870,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
switch (choice)
{
case UnfinishedPlanningModalResult.Resume:
- ForegroundHelper.AllowAny();
- await _worker.ResumePlanningSessionAsync(row.Id);
+ // Resume as an embedded ConPTY pane (server-side ResumeAsync runs inside
+ // GetPlanningResumeLaunchSpec when the pane opens).
+ OpenPlanningConPtyRequested?.Invoke(row.Id, true);
break;
case UnfinishedPlanningModalResult.FinalizeNow:
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
index 8066f6c4..649f7a06 100644
--- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
@@ -234,6 +234,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
OpenMissionControl();
_ = MissionControl.OpenConPtySessionAsync(taskId);
};
+ Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
+ {
+ OpenMissionControl();
+ _ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume);
+ };
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
Tasks.OpenListSettingsRequested += (_, _) =>
{
diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
index c65a8032..b7a3966e 100644
--- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
@@ -256,6 +256,45 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
}
+ // Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
+ // pane — the ConPTY replacement for the old external wt planning window. Deduped by TaskId
+ // like OpenConPtySessionAsync.
+ public async System.Threading.Tasks.Task OpenPlanningConPtySessionAsync(string taskId, bool resume)
+ {
+ if (string.IsNullOrEmpty(taskId)) return;
+ if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
+ {
+ FocusedPane = existing;
+ return;
+ }
+
+ var title = taskId;
+ try
+ {
+ await using var ctx = await _dbFactory.CreateDbContextAsync();
+ var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
+ if (entity?.Title is { Length: > 0 } t) title = t;
+ }
+ catch { /* best-effort title lookup */ }
+ title += Loc.T("missionControl.planningTitleSuffix");
+
+ try
+ {
+ var spec = resume
+ ? await _worker.GetPlanningResumeLaunchSpecAsync(taskId)
+ : await _worker.GetPlanningStartLaunchSpecAsync(taskId);
+ var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
+ var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
+ pane.ErrorReported += OnConPtyPaneError;
+ pane.CloseRequested += CloseConPtySession;
+ ConPtySessions.Add(pane);
+ }
+ catch (Exception ex)
+ {
+ ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
+ }
+ }
+
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
public async System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
index 316e74e6..cc096214 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
@@ -55,10 +55,10 @@ public partial class TaskRowView : UserControl
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
}
- private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
+ private void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
- await vm.OpenPlanningSessionCommand.ExecuteAsync(row);
+ vm.OpenPlanningSessionCommand.Execute(row);
}
private void OnOpenConPtySessionClick(object? sender, RoutedEventArgs e)
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index 3dffca1a..79c97dd8 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -673,6 +673,38 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
});
+ // Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
+ // the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
+ // StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
+ // session is discarded (no children exist yet), mirroring StartPlanningSessionAsync.
+ public Task GetPlanningStartLaunchSpec(string taskId) => HubGuard(async () =>
+ {
+ if (_interactiveLaunchSpec is null)
+ throw new InvalidOperationException("Interactive launch spec service is not configured.");
+ var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
+ try
+ {
+ var spec = _interactiveLaunchSpec.BuildPlanningStart(ctx);
+ await Clients.All.SendAsync("TaskUpdated", taskId);
+ return spec;
+ }
+ catch
+ {
+ await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
+ throw;
+ }
+ });
+
+ // Resumes a planning session and returns the ConPTY launch spec (--permission-mode plan
+ // --resume). ConPTY replacement for ResumePlanningSessionAsync's external wt window.
+ public Task GetPlanningResumeLaunchSpec(string taskId) => HubGuard(async () =>
+ {
+ if (_interactiveLaunchSpec is null)
+ throw new InvalidOperationException("Interactive launch spec service is not configured.");
+ var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
+ return _interactiveLaunchSpec.BuildPlanningResume(ctx);
+ });
+
public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
{
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
index f2c08797..6cad82a2 100644
--- a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
+++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
@@ -74,11 +74,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
var resolvedWt = ResolveWtOrThrow();
var resolvedClaude = ResolveClaudeOrThrow();
- var command = BuildPwshCommand(resolvedClaude, new[]
- {
- "--permission-mode", "plan",
- "--resume", ctx.ClaudeSessionId,
- });
+ var command = BuildPwshCommand(resolvedClaude, BuildPlanningResumeArgs(ctx.ClaudeSessionId));
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
{
@@ -117,13 +113,21 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
// 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)
+ internal static string BuildPlanningStartCommand(string claudePath, PlanningSessionStartContext ctx) =>
+ BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx));
+
+ // The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY
+ // planning path (InteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than
+ // a pwsh-wrapped command line. 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 IReadOnlyList BuildPlanningStartArgs(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[]
+ return new[]
{
"--model", Model,
"--permission-mode", "plan",
@@ -131,9 +135,14 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
"--add-dir", ctx.Files.SessionDirectory,
"--append-system-prompt-file", ctx.Files.SystemPromptPath,
kickoff,
- });
+ };
}
+ // The raw claude CLI args for an interactive planning RESUME, shared with the embedded-ConPTY
+ // planning path. Pins --permission-mode plan (unlike a plain --resume pickup).
+ internal static IReadOnlyList BuildPlanningResumeArgs(string claudeSessionId) =>
+ new[] { "--permission-mode", "plan", "--resume", claudeSessionId };
+
private string ResolveWtOrThrow() =>
Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}");
diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
index 65a5ea65..0c5c1651 100644
--- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
+++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
@@ -107,6 +107,40 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return new LaunchSpec(worktree.Path, resolvedClaude, args, env);
}
+ public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx)
+ {
+ var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
+ ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
+
+ // Mirrors the env the wt planning launcher set (MAX_THINKING_TOKENS + the per-session
+ // planning token); MCP_TOOL_TIMEOUT matches the other embedded-ConPTY specs. Applied to
+ // the UI process env at spawn time (see PtyTerminalSession) — process-global by design.
+ var env = new Dictionary
+ {
+ ["MAX_THINKING_TOKENS"] = "20000",
+ ["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
+ ["MCP_TOOL_TIMEOUT"] = "200000",
+ };
+
+ return new LaunchSpec(
+ ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), env);
+ }
+
+ public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
+ {
+ var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
+ ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
+
+ var env = new Dictionary
+ {
+ ["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
+ ["MCP_TOOL_TIMEOUT"] = "200000",
+ };
+
+ return new LaunchSpec(
+ ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), env);
+ }
+
public Task BuildForDirectoryAsync(string directory, CancellationToken ct)
{
if (!Directory.Exists(directory))
diff --git a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs
index 8a3719c9..bfca55e7 100644
--- a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs
+++ b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs
@@ -1,7 +1,18 @@
+using ClaudeDo.Worker.Planning;
+
namespace ClaudeDo.Worker.Runner;
public interface IInteractiveLaunchSpecService
{
+ /// Maps an already-prepared planning START context (worktree + prompt files + token,
+ /// produced by PlanningSessionManager.StartAsync) into a LaunchSpec for an embedded ConPTY
+ /// planning session — same planning CLI args as the wt launcher, planning env carried in Env.
+ LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx);
+
+ /// Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a
+ /// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume).
+ LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx);
+
/// Builds a LaunchSpec for opening an interactive session in a task's worktree.
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on
diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
index 44c00c05..d0b399a9 100644
--- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
+++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
@@ -99,6 +99,10 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary()));
public virtual Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary()));
+ public virtual Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
+ => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary()));
+ public virtual Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
+ => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary()));
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0));
diff --git a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs
index 2a302aa7..6c0ad077 100644
--- a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs
@@ -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()
{
diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
index 0d777388..5c65ffc2 100644
--- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
@@ -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(
() => 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);
+ }
}
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
index d1a31dc6..dd877a7d 100644
--- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
@@ -76,6 +76,12 @@ sealed class FakeWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary()));
public Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary()));
+ public int PlanningStartSpecCalls { get; private set; }
+ public int PlanningResumeSpecCalls { get; private set; }
+ public Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
+ { PlanningStartSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); }
+ public Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
+ { PlanningResumeSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); }
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
public Task 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)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)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
+ vm.OpenPlanningSessionCommand.Execute(row);
- Assert.Equal(1, worker.StartPlanningCalls);
+ Assert.Equal(("t1", false), raised);
}
[Fact]