diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
index bab8d1df..8b0ba673 100644
--- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
@@ -87,6 +87,9 @@ public interface IWorkerClient : INotifyPropertyChanged
// Picks up a task's Claude session in a real terminal window (--resume) — distinct
// from OpenInteractiveTerminalAsync (the in-app streaming session).
Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default);
+ /// Launch spec for an embedded ConPTY terminal to open an interactive session
+ /// in a task's worktree (same worktree prep as an autonomous run).
+ Task GetInteractiveLaunchSpecAsync(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 09677cb3..cd250ce6 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -564,6 +564,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("ResumeTaskInTerminal", taskId, ct);
+ public async Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
+ => await _hub.InvokeAsync("GetInteractiveLaunchSpec", taskId, ct);
+
public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
@@ -677,6 +680,12 @@ public sealed record WorktreeOverviewDto(
DateTime CreatedAt,
bool PathExistsOnDisk);
+public sealed record LaunchSpec(
+ string Cwd,
+ string Exe,
+ IReadOnlyList Args,
+ IReadOnlyDictionary Env);
+
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index 3d9a5cf7..98a2fad6 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -130,6 +130,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly InteractiveSessionService _interactive;
private readonly LogRingBuffer? _logBuffer;
private readonly ISessionSkillRegistry _skillRegistry;
+ private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;
public WorkerHub(
QueueService queue,
@@ -157,7 +158,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
Runner.PendingQuestionRegistry pendingQuestions,
InteractiveSessionService interactive,
ISessionSkillRegistry skillRegistry,
- LogRingBuffer? logBuffer = null)
+ LogRingBuffer? logBuffer = null,
+ IInteractiveLaunchSpecService? interactiveLaunchSpec = null)
{
_queue = queue;
_waker = waker;
@@ -185,6 +187,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_interactive = interactive;
_skillRegistry = skillRegistry;
_logBuffer = logBuffer;
+ _interactiveLaunchSpec = interactiveLaunchSpec;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -657,6 +660,17 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
}
});
+ // Builds the launch spec an embedded ConPTY terminal (UI process) needs to open an
+ // interactive Claude session in a task's worktree -- same worktree prep as an
+ // autonomous run (session-skills seeding, run env vars), --resume if the task has a
+ // persisted session or a fresh-start spec otherwise. Guards mirror ResumeTaskInTerminal.
+ public Task GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
+ {
+ if (_interactiveLaunchSpec is null)
+ throw new InvalidOperationException("Interactive launch spec service is not configured.");
+ return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
+ });
+
public Task SendInteractiveMessage(string taskId, string text) =>
_interactive.SendAsync(taskId, text, Context.ConnectionAborted);
diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
index 274da92c..f2c08797 100644
--- a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
+++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs
@@ -106,7 +106,12 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
// Resumes a session by id in default (interactive) permission mode: the user drives
// tool approvals in the terminal, unlike planning which pins --permission-mode plan.
internal static string BuildResumeCommand(string claudePath, string claudeSessionId) =>
- BuildPwshCommand(claudePath, new[] { "--resume", claudeSessionId });
+ BuildPwshCommand(claudePath, BuildResumeArgs(claudeSessionId));
+
+ // The raw claude CLI args for a --resume launch, shared with InteractiveLaunchSpecService
+ // (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line).
+ internal static IReadOnlyList BuildResumeArgs(string claudeSessionId) =>
+ new[] { "--resume", claudeSessionId };
// Builds the PowerShell command that launches an interactive planning session.
// Arg order matters: variadic flags (--allowedTools, --add-dir) come first; the
@@ -174,7 +179,9 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
?? throw new TerminalLaunchException("Failed to start Windows Terminal process.");
}
- private static string? Resolve(string pathOrName)
+ // Not private: reused by InteractiveLaunchSpecService to resolve the claude executable
+ // for a ConPTY-hosted launch spec the same way a wt-launched terminal resolves it.
+ internal static string? Resolve(string pathOrName)
{
if (File.Exists(pathOrName))
return pathOrName;
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index c2678b47..59de7900 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -77,6 +77,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
new file mode 100644
index 00000000..91b50b59
--- /dev/null
+++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
@@ -0,0 +1,95 @@
+using ClaudeDo.Data;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.Planning;
+using ClaudeDo.Worker.Skills;
+using Microsoft.EntityFrameworkCore;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Runner;
+
+// Builds the launch spec an embedded ConPTY terminal needs to open an interactive Claude
+// session in an existing task's worktree -- the SAME worktree prep as an autonomous run:
+// session-skills seeded onto disk (reuses ISessionSkillSeeder + TaskRunner.UnionSkillNames,
+// exactly like TaskRunner.RunAsync/ContinueAsync) and the same run environment variables
+// (reuses ClaudeProcess's MCP_TOOL_TIMEOUT). Exe/Args reuse WindowsTerminalLauncher's
+// --resume argument construction. Guards mirror WorkerHub.ResumeTaskInTerminal, except a
+// never-run task (no persisted SessionId) is not an error here -- it's a fresh-start spec.
+//
+// The run-scoped "claudedo_run" MCP server (AskUser/SuggestImprovement) that TaskRunner
+// wires per headless run is intentionally NOT reused: it exists so an unattended run can
+// ask the user a question, which is moot when the user is already driving the session by
+// hand. The always-on `mcp__claudedo__*` tools remain available via the globally-registered
+// MCP server (installer's RegisterMcpStep), exactly as they already are for a plain
+// `--resume` pickup in a Windows Terminal window.
+public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
+{
+ private readonly IDbContextFactory _dbFactory;
+ private readonly ISessionSkillSeeder _skillSeeder;
+ private readonly ISessionSkillRegistry _skillRegistry;
+ private readonly string _claudePath;
+
+ public InteractiveLaunchSpecService(
+ IDbContextFactory dbFactory,
+ ISessionSkillSeeder skillSeeder,
+ ISessionSkillRegistry skillRegistry,
+ WorkerConfig cfg)
+ {
+ _dbFactory = dbFactory;
+ _skillSeeder = skillSeeder;
+ _skillRegistry = skillRegistry;
+ _claudePath = cfg.ClaudeBin;
+ }
+
+ public async Task BuildForTaskAsync(string taskId, CancellationToken ct)
+ {
+ await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
+
+ var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
+ ?? throw new KeyNotFoundException();
+ if (task.Status is TaskStatus.Running or TaskStatus.Queued)
+ throw new InvalidOperationException("Can't open an interactive session for a running or queued task -- interrupt it first.");
+
+ var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
+ if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
+ throw new InvalidOperationException("This task has no active worktree to open a session in.");
+ if (!Directory.Exists(worktree.Path))
+ throw new InvalidOperationException("The task's worktree directory no longer exists.");
+
+ var listConfig = await new ListRepository(ctx).GetConfigAsync(task.ListId, ct);
+ var globalSettings = await new AppSettingsRepository(ctx).GetAsync(ct);
+ var run = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct);
+
+ var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills);
+ var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct);
+ await _skillSeeder.SeedAsync(worktree.Path, skillNames, isWorktree: true, ct);
+
+ var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
+ ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
+
+ var args = run?.SessionId is { Length: > 0 } sessionId
+ ? WindowsTerminalLauncher.BuildResumeArgs(sessionId)
+ : Array.Empty();
+
+ // 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
+ // every other tool.
+ var env = new Dictionary
+ {
+ ["MCP_TOOL_TIMEOUT"] = "200000",
+ };
+
+ return new LaunchSpec(worktree.Path, resolvedClaude, args, env);
+ }
+
+ private async Task> FilterToInstalledSkillsAsync(IReadOnlyList requested, CancellationToken ct)
+ {
+ if (requested.Count == 0) return requested;
+
+ var installed = (await _skillRegistry.ListAsync(ct))
+ .Select(s => s.Name)
+ .ToHashSet(StringComparer.Ordinal);
+ return requested.Where(installed.Contains).ToList();
+ }
+}
diff --git a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs
new file mode 100644
index 00000000..b0fdc230
--- /dev/null
+++ b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs
@@ -0,0 +1,10 @@
+namespace ClaudeDo.Worker.Runner;
+
+public interface IInteractiveLaunchSpecService
+{
+ /// Builds a LaunchSpec for opening an interactive session in an existing task's
+ /// worktree. Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
+ /// if it's Running/Queued or has no usable worktree. A task that has never run (no persisted
+ /// SessionId) is not an error -- the spec omits --resume for a fresh start.
+ Task BuildForTaskAsync(string taskId, CancellationToken ct);
+}
diff --git a/src/ClaudeDo.Worker/Runner/LaunchSpec.cs b/src/ClaudeDo.Worker/Runner/LaunchSpec.cs
new file mode 100644
index 00000000..171dd387
--- /dev/null
+++ b/src/ClaudeDo.Worker/Runner/LaunchSpec.cs
@@ -0,0 +1,10 @@
+namespace ClaudeDo.Worker.Runner;
+
+// What an embedded ConPTY terminal (UI process) needs to start a real `claude` process for
+// a task's worktree, with the same setup as an autonomous run (session-skills seeded onto
+// disk, the same run environment variables) plus the --resume-vs-fresh-start choice.
+public sealed record LaunchSpec(
+ string Cwd,
+ string Exe,
+ IReadOnlyList Args,
+ IReadOnlyDictionary Env);
diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
index 801cf393..1dea0ecc 100644
--- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
+++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
@@ -105,6 +105,8 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
+ public virtual Task GetInteractiveLaunchSpecAsync(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/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
new file mode 100644
index 00000000..3f97a005
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
@@ -0,0 +1,252 @@
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.Runner;
+using ClaudeDo.Worker.Skills;
+using ClaudeDo.Worker.Tests.Infrastructure;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Tests.Runner;
+
+/// Verifies InteractiveLaunchSpecService's guards mirror WorkerHub.ResumeTaskInTerminal
+/// (Running/Queued rejected, missing/invalid worktree rejected) and that it reuses the
+/// autonomous-run worktree prep: session-skills seeding via ISessionSkillSeeder, and
+/// produces --resume for a resumable task vs. a fresh-start spec for a never-run task.
+/// Never spawns the real claude CLI: ClaudeBin points at a stub file on disk.
+public sealed class InteractiveLaunchSpecServiceTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly string _tempDir;
+ private readonly string _worktreeDir;
+ private readonly string _claudeStubPath;
+ private readonly FakeSessionSkillSeeder _seeder = new();
+ private readonly FakeSessionSkillRegistry _registry = new();
+
+ public InteractiveLaunchSpecServiceTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"cd_ilss_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+
+ _worktreeDir = Path.Combine(_tempDir, "worktree");
+ Directory.CreateDirectory(_worktreeDir);
+
+ _claudeStubPath = Path.Combine(_tempDir, "claude.exe");
+ File.WriteAllText(_claudeStubPath, "stub");
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
+ }
+
+ private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
+ {
+ public List Installed { get; } = new();
+
+ public Task> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
+ public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
+ public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
+ public Task> ListAsync(CancellationToken ct)
+ => Task.FromResult>(Installed);
+ }
+
+ private InteractiveLaunchSpecService BuildService() =>
+ new(_db.CreateFactory(), _seeder, _registry, new WorkerConfig { ClaudeBin = _claudeStubPath });
+
+ private async Task SeedListAsync()
+ {
+ var listId = Guid.NewGuid().ToString();
+ using var ctx = _db.CreateContext();
+ await new ListRepository(ctx).AddAsync(new ListEntity
+ {
+ Id = listId, Name = "L", WorkingDir = _tempDir, CreatedAt = DateTime.UtcNow,
+ });
+ return listId;
+ }
+
+ private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null)
+ {
+ using var ctx = _db.CreateContext();
+ await new TaskRepository(ctx).AddAsync(new TaskEntity
+ {
+ Id = taskId, ListId = listId, Title = "T", Status = status,
+ CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson,
+ });
+ }
+
+ private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? path = null)
+ {
+ using var ctx = _db.CreateContext();
+ ctx.Worktrees.Add(new WorktreeEntity
+ {
+ TaskId = taskId, Path = path ?? _worktreeDir, BranchName = "claudedo/x",
+ BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow,
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ private async Task SeedRunAsync(string taskId, string? sessionId)
+ {
+ using var ctx = _db.CreateContext();
+ await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
+ {
+ Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false,
+ Prompt = "p", SessionId = sessionId,
+ StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow.AddMinutes(-1),
+ ExitCode = 0, ResultMarkdown = "ok",
+ });
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_TaskNotFound_ThrowsKeyNotFound()
+ {
+ var svc = BuildService();
+ await Assert.ThrowsAsync(
+ () => svc.BuildForTaskAsync(Guid.NewGuid().ToString(), CancellationToken.None));
+ }
+
+ [Theory]
+ [InlineData(TaskStatus.Running)]
+ [InlineData(TaskStatus.Queued)]
+ public async Task BuildForTaskAsync_TaskRunningOrQueued_ThrowsInvalidOperation(TaskStatus status)
+ {
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, status);
+ await SeedWorktreeAsync(taskId, WorktreeState.Active);
+
+ var svc = BuildService();
+ await Assert.ThrowsAsync(
+ () => svc.BuildForTaskAsync(taskId, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_NoWorktreeRow_ThrowsInvalidOperation()
+ {
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+
+ var svc = BuildService();
+ await Assert.ThrowsAsync(
+ () => svc.BuildForTaskAsync(taskId, CancellationToken.None));
+ }
+
+ [Theory]
+ [InlineData(WorktreeState.Merged)]
+ [InlineData(WorktreeState.Discarded)]
+ public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_ThrowsInvalidOperation(WorktreeState state)
+ {
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+ await SeedWorktreeAsync(taskId, state);
+
+ var svc = BuildService();
+ await Assert.ThrowsAsync(
+ () => svc.BuildForTaskAsync(taskId, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_WorktreeDirectoryMissing_ThrowsInvalidOperation()
+ {
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+ await SeedWorktreeAsync(taskId, WorktreeState.Active, path: Path.Combine(_tempDir, "does-not-exist"));
+
+ var svc = BuildService();
+ await Assert.ThrowsAsync(
+ () => svc.BuildForTaskAsync(taskId, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_ResumableTask_ProducesResumeArgsAndCwd()
+ {
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+ await SeedWorktreeAsync(taskId, WorktreeState.Active);
+ await SeedRunAsync(taskId, "sess-123");
+
+ var svc = BuildService();
+ var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
+
+ Assert.Equal(_worktreeDir, spec.Cwd);
+ Assert.Equal(_claudeStubPath, spec.Exe);
+ Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args);
+ Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_KeptWorktree_IsAllowed()
+ {
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+ await SeedWorktreeAsync(taskId, WorktreeState.Kept);
+ await SeedRunAsync(taskId, "sess-kept");
+
+ var svc = BuildService();
+ var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
+
+ Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args);
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_NeverRunTask_ProducesFreshStartSpec_NoResumeArg()
+ {
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+ await SeedWorktreeAsync(taskId, WorktreeState.Active);
+ // No TaskRunEntity at all -- never run.
+
+ var svc = BuildService();
+ var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
+
+ Assert.Empty(spec.Args);
+ Assert.Equal(_worktreeDir, spec.Cwd);
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_RunWithoutSessionId_ProducesFreshStartSpec()
+ {
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+ await SeedWorktreeAsync(taskId, WorktreeState.Active);
+ await SeedRunAsync(taskId, sessionId: null);
+
+ var svc = BuildService();
+ var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
+
+ Assert.Empty(spec.Args);
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_SeedsSessionSkills_FilteredToInstalled()
+ {
+ _registry.Installed.Add(new SessionSkillEntity
+ {
+ Name = "installed-skill", SourceUrl = "https://example.com/x.git",
+ PinnedRef = "abc", Subpath = "skills/installed-skill", Description = "d",
+ AddedAt = DateTimeOffset.UtcNow,
+ });
+
+ var listId = await SeedListAsync();
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle,
+ sessionSkillsJson: "[\"installed-skill\",\"missing-skill\"]");
+ await SeedWorktreeAsync(taskId, WorktreeState.Active);
+
+ var svc = BuildService();
+ await svc.BuildForTaskAsync(taskId, CancellationToken.None);
+
+ var call = Assert.Single(_seeder.Calls);
+ Assert.Equal(_worktreeDir, call.WorkingDir);
+ Assert.True(call.IsWorktree);
+ Assert.Equal(new[] { "installed-skill" }, call.SkillNames);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
index a15e275f..c839c3b4 100644
--- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
@@ -77,6 +77,8 @@ sealed class FakeWorkerClient : IWorkerClient
public int PickUpInTerminalCalls { get; private set; }
public string? LastPickUpTaskId { get; private set; }
public Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) { PickUpInTerminalCalls++; LastPickUpTaskId = taskId; return Task.CompletedTask; }
+ public Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
+ => 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)