From 08d1797ead21bb677310168d94d5f4120493db8d Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 28 Sep 2026 11:57:37 +0200 Subject: [PATCH] chore(claude-do): Mission Control: allgemeiner Session-Handoff an eine frische ## Ziel Jede Mission-Control-Session (Ad-hoc UND Task-Session) soll ihre Arbeit an eine frische Session uebergeben koennen: "gib das ab an eine neue Session". Die alte Kachel wird geschlossen, an derselben Position oeffnet eine frische Session im selben cwd, die mit einem Brief startet, den die abgebende Session selbst geschrieben hat. Motivation: eine lange Session mit vollem/verrottetem Kontex ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93 --- docs/explore-notes/conpty-sessions.md | 27 ++++ .../Services/Interfaces/IWorkerClient.cs | 10 +- src/ClaudeDo.Ui/Services/WorkerClient.cs | 13 +- .../MissionControl/ConPtyPaneViewModel.cs | 16 +-- .../ViewModels/MissionControlViewModel.cs | 26 +++- .../External/SessionHandoffMcpTools.cs | 78 ++++++++++ src/ClaudeDo.Worker/Hub/HubBroadcaster.cs | 5 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 18 ++- src/ClaudeDo.Worker/Program.cs | 7 +- .../Runner/InteractiveLaunchSpecService.cs | 135 ++++++++++++++++-- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 6 +- .../ConPtyPaneViewModelTests.cs | 4 +- .../MissionControlViewModelTests.cs | 77 +++++++++- .../External/ExternalMcpToolSchemaTests.cs | 2 +- .../External/SessionHandoffMcpToolsTests.cs | 74 ++++++++++ .../InteractiveLaunchSpecServiceTests.cs | 135 +++++++++++++++++- 16 files changed, 598 insertions(+), 35 deletions(-) create mode 100644 src/ClaudeDo.Worker/External/SessionHandoffMcpTools.cs create mode 100644 tests/ClaudeDo.Worker.Tests/External/SessionHandoffMcpToolsTests.cs 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),