diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
index 8b0ba673..bf031d36 100644
--- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
@@ -90,6 +90,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// 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);
+ /// Launch spec for an ad-hoc interactive session in an arbitrary directory --
+ /// no task, no worktree.
+ Task GetAdHocLaunchSpecAsync(string directory, 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 cd250ce6..afaa3188 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -567,6 +567,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("GetInteractiveLaunchSpec", taskId, ct);
+ public async Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
+ => await _hub.InvokeAsync("GetAdHocLaunchSpec", directory, 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.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index 98a2fad6..90e9227f 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -671,6 +671,15 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
});
+ // Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
+ // no task, no worktree, no session-skills seeding.
+ public Task GetAdHocLaunchSpec(string directory) => HubGuard(() =>
+ {
+ if (_interactiveLaunchSpec is null)
+ throw new InvalidOperationException("Interactive launch spec service is not configured.");
+ return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
+ });
+
public Task SendInteractiveMessage(string taskId, string text) =>
_interactive.SendAsync(taskId, text, Context.ConnectionAborted);
diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
index 91b50b59..1ac10ba1 100644
--- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
+++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs
@@ -28,17 +28,20 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
private readonly IDbContextFactory _dbFactory;
private readonly ISessionSkillSeeder _skillSeeder;
private readonly ISessionSkillRegistry _skillRegistry;
+ private readonly WorktreeManager _wtManager;
private readonly string _claudePath;
public InteractiveLaunchSpecService(
IDbContextFactory dbFactory,
ISessionSkillSeeder skillSeeder,
ISessionSkillRegistry skillRegistry,
+ WorktreeManager wtManager,
WorkerConfig cfg)
{
_dbFactory = dbFactory;
_skillSeeder = skillSeeder;
_skillRegistry = skillRegistry;
+ _wtManager = wtManager;
_claudePath = cfg.ClaudeBin;
}
@@ -52,14 +55,32 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
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);
+ var isFreshWorktree = false;
+
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))
+ {
+ // No usable worktree yet -- if the task's list points at a git repo, create one
+ // on demand via the SAME mechanism an autonomous run uses (WorktreeManager.CreateAsync:
+ // branch naming, base commit resolution, worktree-root strategy, DB registration).
+ var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct);
+ if (list?.WorkingDir is null)
+ throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree.");
+
+ await _wtManager.CreateAsync(task, list, ct);
+ worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
+ ?? throw new InvalidOperationException("Worktree creation did not persist a worktree row.");
+ isFreshWorktree = true;
+ }
+ else 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);
+ // A brand-new worktree has no prior session to resume, regardless of any session
+ // history the task accumulated before its previous worktree went away.
+ var run = isFreshWorktree ? null : await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct);
var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills);
var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct);
@@ -83,6 +104,22 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return new LaunchSpec(worktree.Path, resolvedClaude, args, env);
}
+ public Task BuildForDirectoryAsync(string directory, CancellationToken ct)
+ {
+ if (!Directory.Exists(directory))
+ throw new InvalidOperationException($"Directory does not exist: {directory}");
+
+ var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
+ ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
+
+ var env = new Dictionary
+ {
+ ["MCP_TOOL_TIMEOUT"] = "200000",
+ };
+
+ return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty(), env));
+ }
+
private async Task> FilterToInstalledSkillsAsync(IReadOnlyList requested, CancellationToken ct)
{
if (requested.Count == 0) return requested;
diff --git a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs
index b0fdc230..8a3719c9 100644
--- a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs
+++ b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs
@@ -2,9 +2,17 @@ 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.
+ /// 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
+ /// demand (same mechanism as an autonomous run) provided the task's list has a working
+ /// directory pointing at a git repo -- otherwise throws InvalidOperationException. A task
+ /// that has never run, or whose worktree was just created fresh, gets a fresh-start spec
+ /// (no --resume); an existing worktree with a persisted SessionId gets --resume.
Task BuildForTaskAsync(string taskId, CancellationToken ct);
+
+ /// Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
+ /// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
+ /// directory doesn't exist.
+ Task BuildForDirectoryAsync(string directory, CancellationToken ct);
}
diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
index 1dea0ecc..d7fa5f0b 100644
--- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
+++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
@@ -107,6 +107,8 @@ public abstract class StubWorkerClient : IWorkerClient
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 GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
+ => Task.FromResult(new LaunchSpec(directory, "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
index 3f97a005..132e338e 100644
--- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
@@ -1,9 +1,11 @@
+using ClaudeDo.Data.Git;
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 Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Runner;
@@ -21,6 +23,9 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
private readonly string _claudeStubPath;
private readonly FakeSessionSkillSeeder _seeder = new();
private readonly FakeSessionSkillRegistry _registry = new();
+ private readonly List _gitFixtures = new();
+
+ private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public InteractiveLaunchSpecServiceTests()
{
@@ -34,9 +39,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
File.WriteAllText(_claudeStubPath, "stub");
}
+ private GitRepoFixture CreateRepo()
+ {
+ var f = new GitRepoFixture();
+ _gitFixtures.Add(f);
+ return f;
+ }
+
public void Dispose()
{
_db.Dispose();
+ foreach (var f in _gitFixtures) f.Dispose();
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
}
@@ -52,15 +65,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
}
private InteractiveLaunchSpecService BuildService() =>
- new(_db.CreateFactory(), _seeder, _registry, new WorkerConfig { ClaudeBin = _claudeStubPath });
+ new(_db.CreateFactory(), _seeder, _registry,
+ new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger.Instance),
+ new WorkerConfig { ClaudeBin = _claudeStubPath });
- private async Task SeedListAsync()
+ private async Task SeedListAsync(string? workingDir = null)
{
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,
+ Id = listId, Name = "L", WorkingDir = workingDir ?? _tempDir, CreatedAt = DateTime.UtcNow,
});
return listId;
}
@@ -122,7 +137,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
}
[Fact]
- public async Task BuildForTaskAsync_NoWorktreeRow_ThrowsInvalidOperation()
+ public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_ThrowsInvalidOperation()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
@@ -133,6 +148,43 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
() => svc.BuildForTaskAsync(taskId, CancellationToken.None));
}
+ [Fact]
+ public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation()
+ {
+ var listId = await SeedListAsync(workingDir: null);
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+
+ var svc = BuildService();
+ await Assert.ThrowsAsync(
+ () => svc.BuildForTaskAsync(taskId, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task BuildForTaskAsync_FreshTask_NoWorktreeButGitRepoConfigured_CreatesWorktreeOnDemand()
+ {
+ if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
+
+ var repo = CreateRepo();
+ var listId = await SeedListAsync(workingDir: repo.RepoDir);
+ var taskId = Guid.NewGuid().ToString();
+ await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
+ // No worktree row seeded -- task has never run.
+
+ var svc = BuildService();
+ var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
+
+ using var readCtx = _db.CreateContext();
+ var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId);
+ Assert.NotNull(wtRow);
+ Assert.Equal(WorktreeState.Active, wtRow!.State);
+ Assert.True(Directory.Exists(wtRow.Path));
+
+ Assert.Equal(wtRow.Path, spec.Cwd);
+ Assert.Empty(spec.Args);
+ Assert.Equal(_claudeStubPath, spec.Exe);
+ }
+
[Theory]
[InlineData(WorktreeState.Merged)]
[InlineData(WorktreeState.Discarded)]
@@ -249,4 +301,25 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.True(call.IsWorktree);
Assert.Equal(new[] { "installed-skill" }, call.SkillNames);
}
+
+ [Fact]
+ public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
+ {
+ var svc = BuildService();
+ var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(_tempDir, spec.Cwd);
+ Assert.Equal(_claudeStubPath, spec.Exe);
+ Assert.Empty(spec.Args);
+ Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
+ Assert.Empty(_seeder.Calls);
+ }
+
+ [Fact]
+ public async Task BuildForDirectoryAsync_NonExistentDirectory_ThrowsInvalidOperation()
+ {
+ var svc = BuildService();
+ await Assert.ThrowsAsync(
+ () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
+ }
}
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
index c839c3b4..1a5105a5 100644
--- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
@@ -79,6 +79,8 @@ sealed class FakeWorkerClient : IWorkerClient
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 GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
+ => Task.FromResult(new LaunchSpec(directory, "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)