feat(worker): resume a task's claude session in a terminal

This commit is contained in:
Mika Kuns
2026-07-23 16:47:13 +02:00
committed by mika kuns
parent 865e12c0de
commit 140ae2fda1
5 changed files with 176 additions and 1 deletions
+33
View File
@@ -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);
@@ -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 <id>) 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
@@ -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