From 140ae2fda1c8f98b6afd6aad3714221f86c7e5a0 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 1 Jul 2026 12:03:35 +0200 Subject: [PATCH] feat(worker): resume a task's claude session in a terminal --- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 33 +++++++ .../Planning/Interfaces/ITerminalLauncher.cs | 4 + .../Planning/WindowsTerminalLauncher.cs | 20 ++++ .../Hub/PlanningHubTests.cs | 97 ++++++++++++++++++- .../Planning/WindowsTerminalLauncherTests.cs | 23 +++++ 5 files changed, 176 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index e8f32871..2cac81f8 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -575,6 +575,39 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public Task OpenInteractiveTerminalAsync(string taskId) => _interactive.StartAsync(taskId, Context.ConnectionAborted); + // Picks up a task's Claude session in a real terminal window (--resume) so the user can + // drive it by hand — distinct from OpenInteractiveTerminalAsync (the in-app streaming + // session). Only for tasks the worker isn't actively running, with a persisted session + // id and a live worktree. + public Task ResumeTaskInTerminal(string taskId) => HubGuard(async () => + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + + var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted) + ?? throw new KeyNotFoundException(); + if (task.Status is TaskStatus.Running or TaskStatus.Queued) + throw new InvalidOperationException("Can't pick up a running or queued task — interrupt it first."); + + var run = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, Context.ConnectionAborted); + if (run?.SessionId is not { Length: > 0 } sessionId) + throw new InvalidOperationException("This task has no resumable Claude session yet."); + + var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted); + if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept)) + throw new InvalidOperationException("This task has no active worktree to resume in."); + if (!Directory.Exists(worktree.Path)) + throw new InvalidOperationException("The task's worktree directory no longer exists."); + + try + { + await _launcher.LaunchResumeAsync(worktree.Path, sessionId, Context.ConnectionAborted); + } + catch (TerminalLaunchException ex) + { + throw new InvalidOperationException(ex.Message); + } + }); + public Task SendInteractiveMessage(string taskId, string text) => _interactive.SendAsync(taskId, text, Context.ConnectionAborted); diff --git a/src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs index 3b938324..acba27f7 100644 --- a/src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs +++ b/src/ClaudeDo.Worker/Planning/Interfaces/ITerminalLauncher.cs @@ -7,6 +7,10 @@ public interface ITerminalLauncher { Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken); Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken); + + // Resumes an arbitrary task's Claude session (--resume ) in a visible terminal so + // the user can pick up the conversation by hand — the "pick up in terminal" action. + Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken); } public sealed class TerminalLaunchException : Exception diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs index 40dd36cd..274da92c 100644 --- a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs @@ -88,6 +88,26 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher return Task.CompletedTask; } + public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken) + { + if (!Directory.Exists(workingDir)) + throw new TerminalLaunchException($"Working directory does not exist: {workingDir}"); + + var resolvedWt = ResolveWtOrThrow(); + var resolvedClaude = ResolveClaudeOrThrow(); + + var command = BuildResumeCommand(resolvedClaude, claudeSessionId); + + StartInWindowsTerminal(resolvedWt, workingDir, command, static _ => { }); + + return Task.CompletedTask; + } + + // 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 }); + // Builds the PowerShell command that launches an interactive planning session. // 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 diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index ff4c6b54..f59b5beb 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -54,7 +54,7 @@ public sealed class PlanningHubTests : IDisposable private WorkerHub CreateHub() { var hub = new WorkerHub( - null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, null!, null!, null!, null!, _db.CreateFactory(), null!, null!, null!, _planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!, null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); @@ -85,6 +85,88 @@ public sealed class PlanningHubTests : IDisposable return (listId, task.Id); } + private async Task SeedRunAsync(string taskId, string? sessionId) + { + await new TaskRunRepository(_ctx).AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), + TaskId = taskId, + RunNumber = 1, + IsRetry = false, + Prompt = "p", + SessionId = sessionId, + }); + } + + private async Task SeedWorktreeAsync(string taskId, WorktreeState state) + { + var path = Path.Combine(_rootDir, $"wt_{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + await new WorktreeRepository(_ctx).AddAsync(new WorktreeEntity + { + TaskId = taskId, + Path = path, + BranchName = "claudedo/x", + BaseCommit = "base", + State = state, + CreatedAt = DateTime.UtcNow, + }); + return path; + } + + [Fact] + public async Task ResumeTaskInTerminal_LaunchesWithWorktreePathAndSessionId() + { + var (_, taskId) = await SeedAsync(); + await SeedRunAsync(taskId, "sess-99"); + var wtPath = await SeedWorktreeAsync(taskId, WorktreeState.Active); + var hub = CreateHub(); + + await hub.ResumeTaskInTerminal(taskId); + + Assert.Equal(1, _launcher.LaunchTerminalResumeCalls); + Assert.Equal(wtPath, _launcher.LastResumeWorkingDir); + Assert.Equal("sess-99", _launcher.LastResumeSessionId); + } + + [Fact] + public async Task ResumeTaskInTerminal_NoSession_Throws() + { + var (_, taskId) = await SeedAsync(); + await SeedRunAsync(taskId, sessionId: null); + await SeedWorktreeAsync(taskId, WorktreeState.Active); + var hub = CreateHub(); + + await Assert.ThrowsAsync(() => hub.ResumeTaskInTerminal(taskId)); + Assert.Equal(0, _launcher.LaunchTerminalResumeCalls); + } + + [Fact] + public async Task ResumeTaskInTerminal_RunningTask_Throws() + { + var (_, taskId) = await SeedAsync(); + await SeedRunAsync(taskId, "sess-1"); + await SeedWorktreeAsync(taskId, WorktreeState.Active); + var task = await _tasks.GetByIdAsync(taskId); + task!.Status = TaskStatus.Running; + await _tasks.UpdateAsync(task); + var hub = CreateHub(); + + await Assert.ThrowsAsync(() => hub.ResumeTaskInTerminal(taskId)); + Assert.Equal(0, _launcher.LaunchTerminalResumeCalls); + } + + [Fact] + public async Task ResumeTaskInTerminal_NoWorktree_Throws() + { + var (_, taskId) = await SeedAsync(); + await SeedRunAsync(taskId, "sess-1"); + var hub = CreateHub(); + + await Assert.ThrowsAsync(() => hub.ResumeTaskInTerminal(taskId)); + Assert.Equal(0, _launcher.LaunchTerminalResumeCalls); + } + [Fact] public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher() { @@ -192,6 +274,19 @@ internal sealed class FakeTerminalLauncher : ITerminalLauncher LaunchResumeCalls++; return Task.CompletedTask; } + + public int LaunchTerminalResumeCalls { get; private set; } + public string? LastResumeWorkingDir { get; private set; } + public string? LastResumeSessionId { get; private set; } + + public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken) + { + if (ShouldThrow) throw new TerminalLaunchException("fake launch failure"); + LaunchTerminalResumeCalls++; + LastResumeWorkingDir = workingDir; + LastResumeSessionId = claudeSessionId; + return Task.CompletedTask; + } } internal sealed class RecordingClientProxy : IClientProxy diff --git a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs index 6ae264fc..2a302aa7 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalLauncherTests.cs @@ -69,4 +69,27 @@ public sealed class WindowsTerminalLauncherTests // The legacy env-var indirection is gone. Assert.DoesNotContain("CLAUDEDO_LAUNCH_PROMPT", command); } + + [Fact] + public void BuildResumeCommand_ResumesSessionSingleQuoted() + { + var command = WindowsTerminalLauncher.BuildResumeCommand("claude.exe", "sess-42"); + + Assert.Contains("--resume", command); + // The session id is single-quoted so ';' and friends pass through untouched. + Assert.Contains("'--resume' 'sess-42'", command); + // A plain resume drives the real interactive TUI — no planning-only flags. + Assert.DoesNotContain("--permission-mode", command); + Assert.DoesNotContain("--append-system-prompt-file", command); + } + + [Fact] + public async Task LaunchResumeAsync_WorkingDirMissing_Throws() + { + var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude"); + var missing = Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid()); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchResumeAsync(missing, "sess-1", CancellationToken.None)); + Assert.Contains("Working directory", ex.Message); + } }