feat(worker): in-app interactive session service, replacing the wt terminal launch

InteractiveSessionService resolves a task's list working dir + seeded prompt,
spawns a StreamingClaudeSession (claude stream-json in the list dir, model+auto
as before), registers it in LiveSessionRegistry, streams output over TaskMessage,
and broadcasts InteractiveSessionStarted/Ended (an exit watcher fires Ended). The
hub's OpenInteractiveTerminalAsync now starts this in-app session; SendInteractiveMessage
and StopInteractiveSession route to it. The external Windows-Terminal interactive
launch (LaunchInteractiveAsync / InteractiveLaunchContext / OpenInteractiveAsync) is
removed; planning sessions keep their terminal launch.
This commit is contained in:
Mika Kuns
2026-06-26 09:17:11 +02:00
parent d8a043fae7
commit 30e87e698e
13 changed files with 475 additions and 84 deletions

View File

@@ -77,4 +77,10 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
RefineFinished(taskId, success, error);
public Task InteractiveSessionStarted(string taskId) =>
_hub.Clients.All.SendAsync("InteractiveSessionStarted", taskId);
public Task InteractiveSessionEnded(string taskId) =>
_hub.Clients.All.SendAsync("InteractiveSessionEnded", taskId);
}

View File

@@ -9,6 +9,7 @@ using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Logging;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Refine;
@@ -116,6 +117,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly OnlineInboxConfig _onlineInboxConfig;
private readonly OnlineTokenStore _onlineTokenStore;
private readonly Runner.PendingQuestionRegistry _pendingQuestions;
private readonly InteractiveSessionService _interactive;
private readonly LogRingBuffer? _logBuffer;
public WorkerHub(
@@ -142,6 +144,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
OnlineInboxConfig onlineInboxConfig,
OnlineTokenStore onlineTokenStore,
Runner.PendingQuestionRegistry pendingQuestions,
InteractiveSessionService interactive,
LogRingBuffer? logBuffer = null)
{
_queue = queue;
@@ -167,6 +170,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_onlineInboxConfig = onlineInboxConfig;
_onlineTokenStore = onlineTokenStore;
_pendingQuestions = pendingQuestions;
_interactive = interactive;
_logBuffer = logBuffer;
}
@@ -568,11 +572,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return ctx;
}
public async Task OpenInteractiveTerminalAsync(string taskId)
{
var ctx = await _planning.OpenInteractiveAsync(taskId, Context.ConnectionAborted);
await _launcher.LaunchInteractiveAsync(ctx, Context.ConnectionAborted);
}
public Task OpenInteractiveTerminalAsync(string taskId) =>
_interactive.StartAsync(taskId, Context.ConnectionAborted);
public Task SendInteractiveMessage(string taskId, string text) =>
_interactive.SendAsync(taskId, text, Context.ConnectionAborted);
public Task StopInteractiveSession(string taskId) =>
_interactive.StopAsync(taskId, Context.ConnectionAborted);
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
{

View File

@@ -0,0 +1,147 @@
using System.Text;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Runner.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Planning;
public sealed class InteractiveSessionService
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerConfig _cfg;
private readonly HubBroadcaster _broadcaster;
private readonly LiveSessionRegistry _registry;
private readonly ILoggerFactory _loggerFactory;
// Optional factory for tests. Signature: (onLine) -> (session, waitForExitTask).
// The waitForExitTask completes when the underlying process has exited.
private readonly Func<string, IReadOnlyList<string>, Func<string, Task>, (ILiveSession session, Task exitTask)>? _sessionFactory;
public InteractiveSessionService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerConfig cfg,
HubBroadcaster broadcaster,
LiveSessionRegistry registry,
ILoggerFactory loggerFactory,
Func<string, IReadOnlyList<string>, Func<string, Task>, (ILiveSession session, Task exitTask)>? sessionFactory = null)
{
_dbFactory = dbFactory;
_cfg = cfg;
_broadcaster = broadcaster;
_registry = registry;
_loggerFactory = loggerFactory;
_sessionFactory = sessionFactory;
}
public async Task StartAsync(string taskId, CancellationToken ct)
{
if (_registry.TryGet(taskId, out _))
throw new InvalidOperationException("An interactive session is already running for this task.");
await using var ctx = _dbFactory.CreateDbContext();
var tasks = new TaskRepository(ctx);
var lists = new ListRepository(ctx);
var task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await lists.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException($"List {task.ListId} not found.");
var workingDir = list.WorkingDir;
if (string.IsNullOrWhiteSpace(workingDir) || !Directory.Exists(workingDir))
throw new InvalidOperationException(
$"List '{list.Name}' has no valid working directory configured.");
var seededPrompt = BuildInteractivePrompt(task);
var args = new[]
{
"-p",
"--input-format", "stream-json",
"--output-format", "stream-json",
"--verbose",
"--replay-user-messages",
"--model", ModelRegistry.PlanningAlias,
"--permission-mode", "auto",
};
Func<string, Task> onLine = line => _broadcaster.TaskMessage(taskId, "[stdout] " + line);
ILiveSession session;
Task exitTask;
if (_sessionFactory is not null)
{
// Factory is responsible for providing a ready-to-use session and its exit signal.
(session, exitTask) = _sessionFactory(workingDir, args, onLine);
}
else
{
var transport = new ProcessClaudeStreamTransport(
_cfg,
_loggerFactory.CreateLogger<ProcessClaudeStreamTransport>());
var streamingSession = new StreamingClaudeSession(
transport,
onLine,
_loggerFactory.CreateLogger<StreamingClaudeSession>());
await streamingSession.StartAsync(args, workingDir, seededPrompt, ct);
session = streamingSession;
exitTask = transport.WaitForExitAsync();
}
_registry.Register(taskId, session);
await _broadcaster.InteractiveSessionStarted(taskId);
var logger = _loggerFactory.CreateLogger<InteractiveSessionService>();
_ = WatchExitAsync(taskId, exitTask, logger);
}
private async Task WatchExitAsync(string taskId, Task exitTask, ILogger logger)
{
try
{
await exitTask;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Interactive session exit watcher caught an exception for task {task_id}", taskId);
}
finally
{
_registry.Unregister(taskId);
try { await _broadcaster.InteractiveSessionEnded(taskId); }
catch (Exception ex) { logger.LogWarning(ex, "InteractiveSessionEnded broadcast failed for task {task_id}", taskId); }
}
}
public async Task SendAsync(string taskId, string text, CancellationToken ct)
{
if (!_registry.TryGet(taskId, out var session))
throw new InvalidOperationException("No interactive session is running for this task.");
await session.SendUserMessageAsync(text, ct);
}
public async Task StopAsync(string taskId, CancellationToken ct)
{
// StopAsync removes from registry and kills the session.
// The exit watcher will fire InteractiveSessionEnded once the process exits,
// so we don't broadcast here — the watcher is the single authoritative source.
await _registry.StopAsync(taskId);
}
private static string BuildInteractivePrompt(TaskEntity task)
{
var sb = new StringBuilder();
sb.AppendLine($"# Task: {task.Title}");
if (!string.IsNullOrWhiteSpace(task.Description))
{
sb.AppendLine();
sb.AppendLine(task.Description);
}
return sb.ToString();
}
}

View File

@@ -1,13 +1,12 @@
namespace ClaudeDo.Worker.Planning;
// Launches the Claude CLI in a visible terminal for human-driven sessions:
// planning (start/resume) and the ad-hoc "Run interactively" action. Not used for
// headless task execution (that path is ClaudeProcess, prompt over stdin).
// Launches the Claude CLI in a visible terminal for human-driven planning sessions.
// Not used for headless task execution (that path is ClaudeProcess, prompt over stdin)
// nor for interactive sessions (those use InteractiveSessionService + StreamingClaudeSession).
public interface ITerminalLauncher
{
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken);
}
public sealed class TerminalLaunchException : Exception

View File

@@ -5,11 +5,6 @@ public sealed record PlanningSessionFiles(
string SystemPromptPath,
string InitialPromptPath);
public sealed record InteractiveLaunchContext(
string TaskId,
string WorkingDir,
string InitialPrompt);
public sealed class PlanningMcpContext
{
public required string ParentTaskId { get; init; }

View File

@@ -1,5 +1,4 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
@@ -161,40 +160,6 @@ public sealed class PlanningSessionManager
Files: files);
}
public async Task<InteractiveLaunchContext> OpenInteractiveAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, _, ctx) = CreateRepos();
await using var __ = ctx;
var task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await lists.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException($"List {task.ListId} not found.");
var workingDir = list.WorkingDir;
if (string.IsNullOrWhiteSpace(workingDir) || !Directory.Exists(workingDir))
throw new InvalidOperationException(
$"List '{list.Name}' has no valid working directory configured.");
return new InteractiveLaunchContext(
TaskId: taskId,
WorkingDir: workingDir,
InitialPrompt: BuildInteractivePrompt(task));
}
private static string BuildInteractivePrompt(TaskEntity task)
{
var sb = new StringBuilder();
sb.AppendLine($"# Task: {task.Title}");
if (!string.IsNullOrWhiteSpace(task.Description))
{
sb.AppendLine();
sb.AppendLine(task.Description);
}
return sb.ToString();
}
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();

View File

@@ -74,29 +74,6 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
return Task.CompletedTask;
}
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
{
if (!Directory.Exists(ctx.WorkingDir))
throw new TerminalLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
var resolvedWt = ResolveWtOrThrow();
var resolvedClaude = ResolveClaudeOrThrow();
var command = BuildPwshCommand(resolvedClaude, new[]
{
"--model", Model,
"--permission-mode", "auto",
}, appendPrompt: true);
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
{
env["MAX_THINKING_TOKENS"] = "20000";
env[PromptEnvVar] = ctx.InitialPrompt;
});
return Task.CompletedTask;
}
public Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
{
if (!Directory.Exists(ctx.WorkingDir))

View File

@@ -80,6 +80,8 @@ builder.Services.AddSingleton<TaskMergeService>();
builder.Services.AddSingleton<PlanningAggregator>();
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
builder.Services.AddSingleton<PlanningChainCoordinator>();
builder.Services.AddSingleton<LiveSessionRegistry>();
builder.Services.AddSingleton<InteractiveSessionService>();
// Queue dispatch primitives. QueueWaker holds the wake semaphore; the queue picker
// performs atomic Queued→Running claim. Both injected into the state service so it