diff --git a/docs/explore-notes/conpty-sessions.md b/docs/explore-notes/conpty-sessions.md index 1f1c0141..4b7ddd05 100644 --- a/docs/explore-notes/conpty-sessions.md +++ b/docs/explore-notes/conpty-sessions.md @@ -97,6 +97,33 @@ Net effect: reopening an interactive session for a task (pane closed, process ki resumes the same claude conversation, because the id was committed to the DB before the previous launch even started. +## Session handoff (`handoff_session`) + +Task and ad-hoc tiles can hand off to a fresh session in the same tile. The **paneId is the pane's +`IdentityKey`**: the task id for a task tile, a GUID minted by `OpenAdHocConPtySessionAsync` for an +ad-hoc one. + +**The model never passes an id** (it guesses GUIDs). Instead, every such spec gets +`--mcp-config ~/.claudeDo/pane-sessions//mcp.json`, which adds a second entry, +`claudedo_pane`. It points at the same external MCP, with headers `X-ClaudeDo-Pane` and +`X-ClaudeDo-Session` (plus `X-ClaudeDo-Key` when one is configured). An `AddListToolsFilter` +(`SessionHandoffMcpTools.FilterByPane`) splits the tool list by that header: +- `claudedo_pane` lists only `handoff_session`; +- the plain `claudedo` entry lists everything except `handoff_session`. + +The hint (`handoff-hint.md`, via `--append-system-prompt-file`) says when to call +`mcp__claudedo_pane__handoff_session(brief)` and what goes into the brief, and contains no ids. Ad-hoc +tiles get a pre-assigned `--session-id` so the header has one. With `ExternalMcpPort = 0` the tile +gets neither the entry nor the hint. + +`handoff_session(brief)` writes `pane-sessions//handoffs//brief.md` (first line: +`Handed off from session `) and broadcasts `SessionHandoffRequested(paneId, briefPath)`. The +worker has no pane registry: the UI looks up the pane by `IdentityKey`, takes its `Cwd` (from the +resolved launch spec) and its `TaskId`, and closes it. It then reopens the tile at the same slot +with the same `IdentityKey` via `GetSessionHandoffLaunchSpec` → `BuildHandoffAsync`. For a task +tile, the new session id replaces `InteractiveSessionId`, so reopening the task resumes the +handed-off session. The old transcript remains under `~/.claude/projects/`. + ## List handler ("Let Claude handle it") `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 04ade81f..a7d2f938 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged /// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final"). event Action, string>? HandoffRequestedEvent; + /// A Mission Control session called handoff_session: (paneId, briefPath). The UI replaces + /// the tile whose IdentityKey is paneId with a fresh session starting from the brief. + event Action? SessionHandoffRequestedEvent; + /// start_list_handler was called through the external MCP: (listId, taskIds). The UI /// starts a list-handler run for them exactly like the "Let Claude handle it" button. event Action>? ListHandlerStartRequestedEvent; @@ -133,8 +137,10 @@ public interface IWorkerClient : INotifyPropertyChanged /// 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); + /// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint. + Task GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default); + /// Launch spec for the fresh session a tile hands off to after handoff_session. + Task GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default); /// Launch spec for an embedded ConPTY "merge helper" session that drives the given /// tasks to a merged/Done state. listId scopes the session (and cwd) to that list. /// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 979255c3..c4c37017 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; public event Action, string>? HandoffRequestedEvent; + public event Action? SessionHandoffRequestedEvent; public event Action>? ListHandlerStartRequestedEvent; public event Action? HandlerUsageWarningEvent; public event Action? ConnectionRestoredEvent; @@ -160,6 +161,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase)); }); + _hub.On("SessionHandoffRequested", (paneId, briefPath) => + { + Dispatcher.UIThread.Post(() => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath)); + }); + _hub.On>("ListHandlerStartRequested", (listId, taskIds) => { Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds)); @@ -620,8 +626,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) => await InvokeTimedAsync("GetInteractiveLaunchSpec", () => _hub.InvokeAsync("GetInteractiveLaunchSpec", taskId, ct)); - public async Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) - => await InvokeTimedAsync("GetAdHocLaunchSpec", () => _hub.InvokeAsync("GetAdHocLaunchSpec", directory, ct)); + public async Task GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default) + => await InvokeTimedAsync("GetAdHocLaunchSpec", () => _hub.InvokeAsync("GetAdHocLaunchSpec", directory, paneId, ct)); + + public async Task GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default) + => await InvokeTimedAsync("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct)); public async Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default) => await InvokeTimedAsync("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct)); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs index 7ce7b88d..768936a7 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs @@ -79,7 +79,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable public event Action? SessionDiedAfterStartup; /// Task-based pane — dedup'd by . Pass null for an ad-hoc pane - /// (no task, never deduped); prefer at ad-hoc call sites. + /// (no task, never deduped) — its host sets , the tile's paneId. /// is resolved by the pane itself (in ) /// so the tile — and its starting spinner — is visible while the launch spec is still being /// fetched. The host must wire its handlers and then call . @@ -132,11 +132,17 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable /// so a failed launch is not swallowed. public void Start() => _ = StartAsync(); + /// Working directory of the launched session, known once the launch spec resolved — + /// a handoff reopens the fresh session there. + public string? Cwd { get; private set; } + private async System.Threading.Tasks.Task StartAsync() { try { - Terminal.Start(await _descriptorFactory()); + var descriptor = await _descriptorFactory(); + Cwd = descriptor.Cwd; + Terminal.Start(descriptor); } catch (Exception ex) { @@ -144,12 +150,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable } } - /// Ad-hoc pane — no task, no dedup. - public static ConPtyPaneViewModel CreateAdHoc( - string displayTitle, - Func> descriptorFactory) - => new(null, displayTitle, descriptorFactory); - private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error) diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index e720d79c..5756b1c3 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -108,6 +108,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); }; _worker.HandoffRequestedEvent += _onHandoffRequested; + _worker.SessionHandoffRequestedEvent += OpenSessionHandoff; _onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); }; _worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested; @@ -322,11 +323,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable var title = Path.GetFileName(directory.TrimEnd('\\', '/')); if (string.IsNullOrEmpty(title)) title = directory; - AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title, - () => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory)))); + // The key is minted here, not in the pane, because the launch spec needs it as the paneId. + var paneId = Guid.NewGuid().ToString("n"); + AddConPtyPane(new ConPtyPaneViewModel(null, title, + () => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory, paneId))) { IdentityKey = paneId }); return System.Threading.Tasks.Task.CompletedTask; } + /// A session called handoff_session: replaces its tile (same slot, same IdentityKey, so + /// same colour) with a fresh session in the same cwd that starts from the brief. No matching tile + /// (closed meanwhile, or another app instance) = nothing to replace. + internal void OpenSessionHandoff(string paneId, string briefPath) + { + if (ConPtySessions.FirstOrDefault(s => s.IdentityKey == paneId) is not { Cwd: { } cwd } outgoing) return; + OpenRequested?.Invoke(); + + var slot = ConPtySessions.IndexOf(outgoing); + var taskId = outgoing.TaskId; + var title = outgoing.DisplayTitle; + CloseConPtySession(outgoing); + + AddConPtyPane(new ConPtyPaneViewModel(taskId, title, + () => DescribeAsync(() => _worker.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId))) + { IdentityKey = paneId }, slot); + } + // List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based: // creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by // TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself @@ -615,6 +636,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.ConnectionRestoredEvent -= _onConnectionRestored; _worker.HandoffRequestedEvent -= _onHandoffRequested; + _worker.SessionHandoffRequestedEvent -= OpenSessionHandoff; _worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested; _worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning; ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; diff --git a/src/ClaudeDo.Worker/External/SessionHandoffMcpTools.cs b/src/ClaudeDo.Worker/External/SessionHandoffMcpTools.cs new file mode 100644 index 00000000..7c10ba17 --- /dev/null +++ b/src/ClaudeDo.Worker/External/SessionHandoffMcpTools.cs @@ -0,0 +1,78 @@ +using System.ComponentModel; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +/// Confirmation of a handoff_session call: where the brief was written. +public sealed record SessionHandoffResult(bool Requested, string BriefPath); + +/// Generic Mission Control handoff. Reached only through the per-tile claudedo_pane +/// MCP entry, whose headers identify the tile and session — the model never passes an id. +[McpServerToolType] +public sealed class SessionHandoffMcpTools +{ + /// Header naming the calling tile (ConPtyPaneViewModel.IdentityKey). + public const string PaneHeader = "X-ClaudeDo-Pane"; + + /// Header naming the calling session's claude session id. + public const string SessionHeader = "X-ClaudeDo-Session"; + + /// The only tool the claudedo_pane entry lists, and the one the plain entry hides. + public const string ToolName = "handoff_session"; + + private readonly HubBroadcaster _broadcaster; + private readonly IHttpContextAccessor _http; + + public SessionHandoffMcpTools(HubBroadcaster broadcaster, IHttpContextAccessor http) + { + _broadcaster = broadcaster; + _http = http; + } + + /// Writes the brief under the tile's session dir and asks the UI to replace the tile. + [McpServerTool(Name = ToolName), Description( + "Hand this session off to a fresh session in the same Mission Control tile and directory, when the " + + "user asks for it (e.g. the context is full). The brief is the ONLY context the fresh session gets -- " + + "make it self-contained. This tile closes right after the call; end your turn immediately.")] + public async Task HandoffSession( + [Description("Markdown brief for the fresh session: goal, done, decisions, current state, next steps, open questions, file paths.")] string brief, + CancellationToken cancellationToken = default) + { + var headers = _http.HttpContext?.Request.Headers; + var paneId = headers?[PaneHeader].ToString(); + if (string.IsNullOrEmpty(paneId)) + throw new InvalidOperationException("handoff_session only works from a ClaudeDo Mission Control tile."); + if (string.IsNullOrWhiteSpace(brief)) + throw new InvalidOperationException("brief must not be empty."); + + var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(dir); + var briefPath = Path.Combine(dir, "brief.md"); + var fromSession = headers![SessionHeader].ToString(); + var header = fromSession.Length == 0 ? "" : $"Handed off from session `{fromSession}`.\n\n"; + await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken); + + await _broadcaster.SessionHandoffRequested(paneId, briefPath); + return new SessionHandoffResult(true, briefPath); + } + + /// List-tools filter: a request carrying (the per-tile entry) sees + /// only handoff_session; every other request sees everything but it. + public static McpRequestHandler FilterByPane( + McpRequestHandler next) => + async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + var http = request.Services?.GetService()?.HttpContext; + result.Tools = result.Tools.Where(t => IsVisible(t.Name, http)).ToList(); + return result; + }; + + /// Visibility rule behind . + internal static bool IsVisible(string toolName, HttpContext? http) => + (toolName == ToolName) == !string.IsNullOrEmpty(http?.Request.Headers[PaneHeader].ToString()); +} diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index e5b5f489..d6e3657f 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -37,6 +37,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task HandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase) => _hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase); + /// A Mission Control session called handoff_session -- the UI replaces the tile whose + /// IdentityKey is paneId with a fresh session that starts from the brief. + public Task SessionHandoffRequested(string paneId, string briefPath) => + _hub.Clients.All.SendAsync("SessionHandoffRequested", paneId, briefPath); + // start_list_handler (External MCP) -- the App creates the handler task and opens the triage // tile, exactly like the "Let Claude handle it" button. public Task ListHandlerStartRequested(string listId, IReadOnlyList taskIds) => diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index c391c596..a825f1f4 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -776,16 +776,26 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub if (_interactiveLaunchSpec is null) throw new InvalidOperationException("Interactive launch spec service is not configured."); _broadcaster.WorktreeUpdated(taskId); - return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted); + // A task tile's paneId is its task id (ConPtyPaneViewModel.IdentityKey). + return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted, paneId: taskId); }); // 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(() => + // no task, no worktree, no session-skills seeding. paneId is the tile's IdentityKey, rendered + // into the handoff hint. + public Task GetAdHocLaunchSpec(string directory, string paneId) => HubGuard(() => { if (_interactiveLaunchSpec is null) throw new InvalidOperationException("Interactive launch spec service is not configured."); - return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted); + return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted); + }); + + /// Launch spec for the fresh session a tile hands off to after handoff_session. + public Task GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() => + { + if (_interactiveLaunchSpec is null) + throw new InvalidOperationException("Interactive launch spec service is not configured."); + return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted); }); // Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 79eb4342..638dda93 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -375,6 +375,8 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddHttpContextAccessor(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); @@ -385,7 +387,9 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddScoped(); externalBuilder.Services.AddMcpServer() .WithHttpTransport(o => o.Stateless = true) - .WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)) + .WithRequestFilters(f => f + .AddCallToolFilter(ExternalMcpExceptionFilter.Wrap) + .AddListToolsFilter(SessionHandoffMcpTools.FilterByPane)) .WithTools() .WithTools() .WithTools() @@ -396,6 +400,7 @@ if (cfg.ExternalMcpPort > 0) .WithTools() .WithTools() .WithTools() + .WithTools() .WithTools() .WithTools() .WithTools() diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index f41b394f..e37c2f98 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.Json; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; @@ -43,6 +44,7 @@ public sealed class InteractiveLaunchSpecService private readonly WorktreeManager _wtManager; private readonly GitService _git; private readonly string _claudePath; + private readonly WorkerConfig _cfg; private readonly HandoffRoundTracker _handoffRounds; private readonly HandlerRoundState _roundState; @@ -67,6 +69,7 @@ public sealed class InteractiveLaunchSpecService _wtManager = wtManager; _git = git; _claudePath = cfg.ClaudeBin; + _cfg = cfg; _handoffRounds = handoffRounds; _roundState = roundState; } @@ -81,8 +84,10 @@ public sealed class InteractiveLaunchSpecService /// it has one, else the latest autonomous run's session; a task that has never run either /// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre- /// assigned a new session id via --session-id and persisted to InteractiveSessionId before - /// launch, so a closed/aborted session can be resumed next time. - public async Task BuildForTaskAsync(string taskId, CancellationToken ct) + /// launch, so a closed/aborted session can be resumed next time. A non-null + /// appends the Mission Control handoff hint (see + /// ). + public async Task BuildForTaskAsync(string taskId, CancellationToken ct, string? paneId = null) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); @@ -184,17 +189,22 @@ public sealed class InteractiveLaunchSpecService // persisted HERE, before launch, so a closed/aborted session -- even one that never got // past startup -- still leaves an id the next open can resume. IReadOnlyList args; + string sessionId; if (resumeSessionId is { Length: > 0 }) { + sessionId = resumeSessionId; args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort); } else { - var sessionId = Guid.NewGuid().ToString(); + sessionId = Guid.NewGuid().ToString(); await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct); args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct); } + if (paneId is not null) + args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args]; + // Same run environment variable ClaudeProcess sets for every headless run: the // AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting // it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT. @@ -253,8 +263,9 @@ public sealed class InteractiveLaunchSpecService /// 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. - public async Task BuildForDirectoryAsync(string directory, CancellationToken ct) + /// directory doesn't exist. A non-null pre-assigns a session id and + /// appends the Mission Control handoff hint. + public async Task BuildForDirectoryAsync(string directory, string? paneId, CancellationToken ct) { if (!Directory.Exists(directory)) throw new InvalidOperationException($"Directory does not exist: {directory}"); @@ -271,9 +282,117 @@ public sealed class InteractiveLaunchSpecService }; // No task and no list here — the global default model's preset decides the effort. - return new LaunchSpec( - directory, resolvedClaude, - WithEffort(Array.Empty(), EffortFor(settings, settings.DefaultModel)), env); + var args = WithEffort(Array.Empty(), EffortFor(settings, settings.DefaultModel)); + if (paneId is not null) + { + // Pre-assigned so the pane entry can carry it -- a handoff records it as the brief's origin. + var sessionId = Guid.NewGuid().ToString(); + args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args, "--session-id", sessionId]; + } + return new LaunchSpec(directory, resolvedClaude, args, env); + } + + /// Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via + /// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool + /// under ) as the single-line kickoff, and the handoff hint again so + /// the chain can continue. With a taskId the new session id becomes the task's + /// InteractiveSessionId, so reopening the task resumes the handed-off session. Throws + /// InvalidOperationException for a missing cwd or a brief outside the pane's own dir. + public async Task BuildHandoffAsync( + string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct) + { + if (!Directory.Exists(cwd)) + throw new InvalidOperationException($"Directory does not exist: {cwd}"); + briefPath = Path.GetFullPath(briefPath); + if (!briefPath.StartsWith(PaneSessionDir(paneId) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || !File.Exists(briefPath)) + throw new InvalidOperationException($"Handoff brief not found: {briefPath}"); + + var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) + ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var settings = await new AppSettingsRepository(ctx).GetAsync(ct); + var sessionId = Guid.NewGuid().ToString(); + + var model = settings.DefaultModel; + if (taskId is not null) + { + var taskRepo = new TaskRepository(ctx); + var task = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}"); + model = task.Model ?? (await new ListRepository(ctx).GetConfigAsync(task.ListId, ct))?.Model; + await taskRepo.SetInteractiveSessionIdAsync(taskId, sessionId, ct); + } + + // --add-dir is variadic: the single-value flags after it end it, and the kickoff stays last. + var args = new List(await PaneSessionArgsAsync(paneId, sessionId, ct)) + { + "--add-dir", Path.GetDirectoryName(briefPath)!, + "--effort", EffortFor(settings, model), + "--session-id", sessionId, + $"Read the file {briefPath} first. It is the handoff brief from the session that worked here " + + "before you. After reading it, continue the work as it describes.", + }; + + var env = new Dictionary + { + ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs, + }; + + return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env); + } + + /// Per-tile dir under the app data root holding the handoff hint and every brief the + /// tile's sessions handed off with. Throws InvalidOperationException unless paneId is a GUID — + /// it becomes a path segment. + public static string PaneSessionDir(string paneId) + { + if (!Guid.TryParse(paneId, out _)) + throw new InvalidOperationException($"Invalid paneId: {paneId}"); + return Path.GetFullPath(Path.Combine(Paths.AppDataRoot(), "pane-sessions", paneId)); + } + + /// Name of the per-tile MCP entry that carries the tile's identity in its headers. + public const string PaneMcpServerName = "claudedo_pane"; + + // `--mcp-config --append-system-prompt-file ` giving the session a `claudedo_pane` + // entry whose headers name this tile and session, so handoff_session needs no id from the model. + // Empty without the external MCP -- there is nothing to hand off through. + private async Task> PaneSessionArgsAsync(string paneId, string sessionId, CancellationToken ct) + { + if (_cfg.ExternalMcpPort <= 0) return []; + + var dir = PaneSessionDir(paneId); + Directory.CreateDirectory(dir); + + var headers = new Dictionary + { + [SessionHandoffMcpTools.PaneHeader] = paneId, + [SessionHandoffMcpTools.SessionHeader] = sessionId, + }; + if (!string.IsNullOrEmpty(_cfg.ExternalMcpApiKey)) + headers["X-ClaudeDo-Key"] = _cfg.ExternalMcpApiKey; + var mcpConfigPath = Path.Combine(dir, "mcp.json"); + await File.WriteAllTextAsync(mcpConfigPath, JsonSerializer.Serialize(new Dictionary + { + ["mcpServers"] = new Dictionary + { + [PaneMcpServerName] = new { type = "http", url = $"http://127.0.0.1:{_cfg.ExternalMcpPort}/mcp", headers }, + }, + }), ct); + + var hintPath = Path.Combine(dir, "handoff-hint.md"); + await File.WriteAllTextAsync(hintPath, + $""" + You are running inside a ClaudeDo Mission Control tile. Only when the user asks you to hand off + to a fresh session, call mcp__{PaneMcpServerName}__{SessionHandoffMcpTools.ToolName} with a brief. + The brief is the ONLY context the fresh session gets, so make it self-contained: the goal, what + is done, decisions made and why, the current state (branch, uncommitted changes, failing tests), + what comes next, open questions, and the relevant file paths. This tile closes and a fresh + session opens in its place right after the call -- end your turn immediately. + """, ct); + + return ["--mcp-config", mcpConfigPath, "--append-system-prompt-file", hintPath]; } // Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll, diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index cbf97e32..df5136cb 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; public event Action, string>? HandoffRequestedEvent; + public event Action? SessionHandoffRequestedEvent; public event Action>? ListHandlerStartRequestedEvent; public event Action? HandlerUsageWarningEvent; public event Action? NotesUpdatedEvent; @@ -59,6 +60,7 @@ public abstract class StubWorkerClient : IWorkerClient public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId); public void RaiseListHandlerStartRequested(string listId, IReadOnlyList taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds); public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase); + public void RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath); public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message); public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList files, bool externallyDriven) => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven); @@ -124,8 +126,10 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task SubmitTaskForReviewAsync(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) + public virtual Task GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary())); + public virtual Task GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(cwd, "claude", Array.Empty(), new Dictionary())); public virtual Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task CreateMergeHelperTaskAsync( diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs index 72c92028..e5d54359 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs @@ -53,7 +53,7 @@ public class ConPtyPaneViewModelTests [Fact] public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning() { - using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes); + using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes); SetRunning(pane); Assert.False(pane.IsTaskBased); @@ -192,7 +192,7 @@ public class ConPtyPaneViewModelTests [Fact] public void SessionDiedAfterStartup_AdHocPane_NeverRaises() { - using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes); + using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes); var raised = false; pane.SessionDiedAfterStartup += (_, _) => raised = true; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index 24005a78..9b4e930e 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -490,7 +490,7 @@ public class MissionControlViewModelTests : IDisposable private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient { - public override Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) + public override Task GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default) => throw new InvalidOperationException("bad directory"); } @@ -771,6 +771,81 @@ public class MissionControlViewModelTests : IDisposable Assert.Equal(color, vm.ConPtySessions[1].IdentityColorKey); } + private sealed class RecordingSessionHandoffWorker : StubWorkerClient + { + public string? AdHocPaneId; + public readonly TaskCompletionSource<(string PaneId, string Cwd, string BriefPath, string? TaskId)> HandoffCall = new(); + public override Task GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default) + { + AdHocPaneId ??= paneId; + return base.GetAdHocLaunchSpecAsync(directory, paneId, ct); + } + public override Task GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default) + { + HandoffCall.TrySetResult((paneId, cwd, briefPath, taskId)); + return base.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId, ct); + } + } + + // handoff_session replaces the tile in place: same slot, same identity colour, same cwd, and the + // outgoing pane is really closed rather than left running beside the new one. + [Fact] + public async Task SessionHandoffRequested_ReplacesPaneInPlace_KeepingIdentityAndCwd() + { + var worker = new RecordingSessionHandoffWorker(); + using var vm = BuildVm(worker); + var dir = Path.Combine("C:", "Some", "Path"); + await vm.OpenConPtySessionAsync("t1"); + await vm.OpenAdHocConPtySessionAsync(dir); + await vm.OpenConPtySessionAsync("t2"); + var outgoing = vm.ConPtySessions[1]; + Assert.Equal(outgoing.IdentityKey, worker.AdHocPaneId); + var color = outgoing.IdentityColorKey; + var opened = 0; + vm.OpenRequested += () => opened++; + + worker.RaiseSessionHandoffRequested(outgoing.IdentityKey, "brief.md"); + + Assert.Equal(3, vm.ConPtySessions.Count); + var incoming = vm.ConPtySessions[1]; + Assert.NotSame(outgoing, incoming); + Assert.Null(incoming.TaskId); + Assert.Equal(outgoing.IdentityKey, incoming.IdentityKey); + Assert.Equal(color, incoming.IdentityColorKey); + Assert.Same(incoming, vm.Panes[1]); + Assert.Equal(1, opened); + Assert.Equal(0, SubscriberCount(outgoing, "SubmitForReviewRequested")); + + var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal((outgoing.IdentityKey, dir, "brief.md", (string?)null), call); + } + + [Fact] + public async Task SessionHandoffRequested_TaskPane_KeepsTaskId() + { + var worker = new RecordingSessionHandoffWorker(); + using var vm = BuildVm(worker); + await vm.OpenConPtySessionAsync("t1"); + + worker.RaiseSessionHandoffRequested("t1", "brief.md"); + + Assert.Equal("t1", Assert.Single(vm.ConPtySessions).TaskId); + var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal("t1", call.TaskId); + } + + [Fact] + public void SessionHandoffRequested_UnknownPane_DoesNothing() + { + var worker = new RecordingSessionHandoffWorker(); + using var vm = BuildVm(worker); + + worker.RaiseSessionHandoffRequested("nope", "brief.md"); + + Assert.Empty(vm.ConPtySessions); + Assert.False(worker.HandoffCall.Task.IsCompleted); + } + [Fact] public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane() { diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs index b6d22979..0e87a888 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs @@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests "list_runs", "get_run", "get_task_log", "list_agents", "reset_failed_task", "submit_task_for_review", - "handoff_list_handler", "get_handoff_status", "start_list_handler", + "handoff_list_handler", "get_handoff_status", "start_list_handler", "handoff_session", "get_app_settings", "wait_for_task_change", "get_queue_state", diff --git a/tests/ClaudeDo.Worker.Tests/External/SessionHandoffMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/SessionHandoffMcpToolsTests.cs new file mode 100644 index 00000000..e29a7503 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/SessionHandoffMcpToolsTests.cs @@ -0,0 +1,74 @@ +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class SessionHandoffMcpToolsTests : IDisposable +{ + private readonly CapturingHubContext _hubContext = new(); + private readonly string _paneId = Guid.NewGuid().ToString("n"); + + public void Dispose() + { + try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(_paneId), true); } catch { } + } + + private static HttpContext Request(string? paneId, string? sessionId = null) + { + var http = new DefaultHttpContext(); + if (paneId is not null) http.Request.Headers[SessionHandoffMcpTools.PaneHeader] = paneId; + if (sessionId is not null) http.Request.Headers[SessionHandoffMcpTools.SessionHeader] = sessionId; + return http; + } + + private SessionHandoffMcpTools BuildSut(HttpContext http) => + new(new HubBroadcaster(_hubContext), new HttpContextAccessor { HttpContext = http }); + + [Fact] + public async Task HandoffSession_IdentityFromHeaders_WritesBriefWithOrigin_AndBroadcasts() + { + var result = await BuildSut(Request(_paneId, "sess-1")).HandoffSession(" do the thing ", CancellationToken.None); + + Assert.True(result.Requested); + Assert.StartsWith(InteractiveLaunchSpecService.PaneSessionDir(_paneId), result.BriefPath); + Assert.Equal("Handed off from session `sess-1`.\n\ndo the thing\n", File.ReadAllText(result.BriefPath)); + + var call = Assert.Single(_hubContext.Proxy.Calls); + Assert.Equal("SessionHandoffRequested", call.Method); + Assert.Equal(new object?[] { _paneId, result.BriefPath }, call.Args); + } + + [Theory] + [InlineData(null)] + [InlineData("not-a-guid")] + [InlineData(@"..\..\evil")] + public async Task HandoffSession_MissingOrInvalidPaneHeader_ThrowsWithoutBroadcast(string? paneId) + { + await Assert.ThrowsAsync(() => + BuildSut(Request(paneId)).HandoffSession("brief", CancellationToken.None)); + Assert.Empty(_hubContext.Proxy.Calls); + } + + [Fact] + public async Task HandoffSession_EmptyBrief_Throws() + { + await Assert.ThrowsAsync(() => + BuildSut(Request(_paneId)).HandoffSession(" ", CancellationToken.None)); + Assert.Empty(_hubContext.Proxy.Calls); + } + + // The per-tile entry lists only handoff_session; the plain entry every session has hides it, so + // the tool is never duplicated and never offered where it can't identify the tile. + [Theory] + [InlineData("handoff_session", true, true)] + [InlineData("handoff_session", false, false)] + [InlineData("get_task", true, false)] + [InlineData("get_task", false, true)] + public void IsVisible_SplitsToolsByPaneHeader(string tool, bool paneRequest, bool expected) + { + Assert.Equal(expected, SessionHandoffMcpTools.IsVisible(tool, Request(paneRequest ? _paneId : null))); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index 1c704ca4..929b4f47 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -60,6 +60,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable try { Directory.Delete(d, true); } catch { /* best effort */ } foreach (var id in _seededTaskIds) try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), true); } catch { /* best effort */ } + foreach (var id in _paneIds) + try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(id), true); } catch { /* best effort */ } } private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry @@ -576,7 +578,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec() { var svc = BuildService(); - var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None); + var spec = await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None); Assert.Equal(_tempDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); @@ -590,7 +592,134 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable { var svc = BuildService(); await Assert.ThrowsAsync( - () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None)); + () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), null, CancellationToken.None)); + } + + // ── Mission Control session handoff ── + + private readonly List _paneIds = new(); + + private string NewPaneId() + { + var id = Guid.NewGuid().ToString("n"); + _paneIds.Add(id); + return id; + } + + /// Asserts the spec leads with the per-tile MCP entry and the handoff hint: the entry's headers + /// name this pane and the session id the spec actually launches/resumes, and the hint names no + /// id at all (the model would guess it). + private static void AssertHandoffHint(LaunchSpec spec, string paneId, string sessionId) + { + Assert.Equal("--mcp-config", spec.Args[0]); + Assert.Equal("--append-system-prompt-file", spec.Args[2]); + + using var mcp = System.Text.Json.JsonDocument.Parse(File.ReadAllText(spec.Args[1])); + var server = mcp.RootElement.GetProperty("mcpServers").GetProperty(InteractiveLaunchSpecService.PaneMcpServerName); + Assert.Equal($"http://127.0.0.1:{new WorkerConfig().ExternalMcpPort}/mcp", server.GetProperty("url").GetString()); + var headers = server.GetProperty("headers"); + Assert.Equal(paneId, headers.GetProperty(SessionHandoffMcpTools.PaneHeader).GetString()); + Assert.Equal(sessionId, headers.GetProperty(SessionHandoffMcpTools.SessionHeader).GetString()); + + var hint = File.ReadAllText(spec.Args[3]); + Assert.Contains("mcp__claudedo_pane__handoff_session", hint); + Assert.DoesNotContain(paneId, hint); + Assert.DoesNotContain(sessionId, hint); + } + + [Fact] + public async Task BuildForDirectoryAsync_ExternalMcpDisabled_NoPaneEntry() + { + var svc = new InteractiveLaunchSpecService(_db.CreateFactory(), _seeder, _registry, + new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger.Instance), + new GitService(), new WorkerConfig { ClaudeBin = _claudeStubPath, ExternalMcpPort = 0 }, + new HandoffRoundTracker(), NewRoundState()); + + var spec = await svc.BuildForDirectoryAsync(_tempDir, NewPaneId(), CancellationToken.None); + + Assert.DoesNotContain("--mcp-config", spec.Args); + } + + private static string ArgAfter(LaunchSpec spec, string flag) => spec.Args[spec.Args.ToList().IndexOf(flag) + 1]; + + [Fact] + public async Task BuildForDirectoryAsync_WithPaneId_PreAssignsSessionIdAndAppendsHint() + { + var paneId = NewPaneId(); + var spec = await BuildService().BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None); + + var sessionId = ArgAfter(spec, "--session-id"); + Assert.True(Guid.TryParse(sessionId, out _)); + AssertHandoffHint(spec, paneId, sessionId); + } + + [Fact] + public async Task BuildForTaskAsync_WithPaneId_ResumedSession_HintNamesResumedId() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + var svc = BuildService(); + var (_, _, _, firstSessionId) = ParseFreshTaskArgs(await svc.BuildForTaskAsync(taskId, CancellationToken.None)); + + var paneId = NewPaneId(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None, paneId); + + AssertHandoffHint(spec, paneId, firstSessionId); + Assert.Equal(firstSessionId, ArgAfter(spec, "--resume")); + } + + private static string WriteBrief(string paneId) + { + var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "brief.md"); + File.WriteAllText(path, "brief"); + return path; + } + + [Fact] + public async Task BuildHandoffAsync_AdHoc_KicksOffFromBriefInSameCwd() + { + var paneId = NewPaneId(); + var briefPath = WriteBrief(paneId); + + var spec = await BuildService().BuildHandoffAsync(paneId, _tempDir + Path.DirectorySeparatorChar, briefPath, null, CancellationToken.None); + + // A trailing separator on the cwd must not survive into the spec (ConPTY quoting trap). + Assert.Equal(_tempDir, spec.Cwd); + var sessionId = ArgAfter(spec, "--session-id"); + AssertHandoffHint(spec, paneId, sessionId); + Assert.Equal(Path.GetDirectoryName(briefPath), ArgAfter(spec, "--add-dir")); + var kickoff = spec.Args[^1]; + Assert.Contains(briefPath, kickoff); + Assert.DoesNotContain('\n', kickoff); + Assert.Equal("--session-id", spec.Args[^3]); + Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]); + } + + [Fact] + public async Task BuildHandoffAsync_Task_PersistsNewSessionIdForResume() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + var briefPath = WriteBrief(taskId); + _paneIds.Add(taskId); + + var spec = await BuildService().BuildHandoffAsync(taskId, _tempDir, briefPath, taskId, CancellationToken.None); + + Assert.Equal(ArgAfter(spec, "--session-id"), await ReadInteractiveSessionIdAsync(taskId)); + } + + [Fact] + public async Task BuildHandoffAsync_BriefOutsidePaneDir_Throws() + { + var outside = Path.Combine(_tempDir, "brief.md"); + File.WriteAllText(outside, "x"); + + await Assert.ThrowsAsync( + () => BuildService().BuildHandoffAsync(NewPaneId(), _tempDir, outside, null, CancellationToken.None)); } // ── Merge helper ── @@ -1482,7 +1611,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var specs = new List { await svc.BuildForTaskAsync(taskId, CancellationToken.None), - await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None), + await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None), mergeHelperSpec, handoffSpec, svc.BuildPlanningStart(planningStartCtx),