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
This commit is contained in:
Mika Kuns
2026-09-28 11:57:37 +02:00
parent e0a1448f44
commit 08d1797ead
16 changed files with 598 additions and 35 deletions
+27
View File
@@ -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/<paneId>/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/<paneId>/handoffs/<guid>/brief.md` (first line:
`Handed off from session <id>`) 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
@@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
/// <summary>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.</summary>
event Action<string, string>? SessionHandoffRequestedEvent;
/// <summary>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.</summary>
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
@@ -133,8 +137,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// in a task's worktree (same worktree prep as an autonomous run).</summary>
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
/// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default);
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default);
/// <summary>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
+11 -2
View File
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, string>? SessionHandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? 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<string, string>("SessionHandoffRequested", (paneId, briefPath) =>
{
Dispatcher.UIThread.Post(() => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath));
});
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
{
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
@@ -620,8 +626,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, paneId, ct));
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
@@ -79,7 +79,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
public event Action<string, int>? SessionDiedAfterStartup;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
/// (no task, never deduped) — its host sets <see cref="IdentityKey"/>, the tile's paneId.
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
/// 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 <see cref="Start"/>.</summary>
@@ -132,11 +132,17 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
public void Start() => _ = StartAsync();
/// <summary>Working directory of the launched session, known once the launch spec resolved —
/// a handoff reopens the fresh session there.</summary>
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
}
}
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
public static ConPtyPaneViewModel CreateAdHoc(
string displayTitle,
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
=> new(null, displayTitle, descriptorFactory);
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
@@ -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;
}
/// <summary>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.</summary>
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;
+78
View File
@@ -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;
/// <summary>Confirmation of a handoff_session call: where the brief was written.</summary>
public sealed record SessionHandoffResult(bool Requested, string BriefPath);
/// <summary>Generic Mission Control handoff. Reached only through the per-tile <c>claudedo_pane</c>
/// MCP entry, whose headers identify the tile and session — the model never passes an id.</summary>
[McpServerToolType]
public sealed class SessionHandoffMcpTools
{
/// <summary>Header naming the calling tile (ConPtyPaneViewModel.IdentityKey).</summary>
public const string PaneHeader = "X-ClaudeDo-Pane";
/// <summary>Header naming the calling session's claude session id.</summary>
public const string SessionHeader = "X-ClaudeDo-Session";
/// <summary>The only tool the <c>claudedo_pane</c> entry lists, and the one the plain entry hides.</summary>
public const string ToolName = "handoff_session";
private readonly HubBroadcaster _broadcaster;
private readonly IHttpContextAccessor _http;
public SessionHandoffMcpTools(HubBroadcaster broadcaster, IHttpContextAccessor http)
{
_broadcaster = broadcaster;
_http = http;
}
/// <summary>Writes the brief under the tile's session dir and asks the UI to replace the tile.</summary>
[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<SessionHandoffResult> 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);
}
/// <summary>List-tools filter: a request carrying <see cref="PaneHeader"/> (the per-tile entry) sees
/// only handoff_session; every other request sees everything but it.</summary>
public static McpRequestHandler<ListToolsRequestParams, ListToolsResult> FilterByPane(
McpRequestHandler<ListToolsRequestParams, ListToolsResult> next) =>
async (request, cancellationToken) =>
{
var result = await next(request, cancellationToken);
var http = request.Services?.GetService<IHttpContextAccessor>()?.HttpContext;
result.Tools = result.Tools.Where(t => IsVisible(t.Name, http)).ToList();
return result;
};
/// <summary>Visibility rule behind <see cref="FilterByPane"/>.</summary>
internal static bool IsVisible(string toolName, HttpContext? http) =>
(toolName == ToolName) == !string.IsNullOrEmpty(http?.Request.Headers[PaneHeader].ToString());
}
@@ -37,6 +37,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
/// <summary>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.</summary>
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<string> taskIds) =>
+14 -4
View File
@@ -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<LaunchSpec> 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<LaunchSpec> 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);
});
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
public Task<LaunchSpec> 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
+6 -1
View File
@@ -375,6 +375,8 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddScoped<HandoffMcpTools>();
externalBuilder.Services.AddScoped<SessionHandoffMcpTools>();
externalBuilder.Services.AddHttpContextAccessor();
externalBuilder.Services.AddScoped<ListHandlerMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
@@ -385,7 +387,9 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<NotesMcpTools>();
externalBuilder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true)
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
.WithRequestFilters(f => f
.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)
.AddListToolsFilter(SessionHandoffMcpTools.FilterByPane))
.WithTools<ExternalMcpService>()
.WithTools<MergeMcpTools>()
.WithTools<WorktreeMcpTools>()
@@ -396,6 +400,7 @@ if (cfg.ExternalMcpPort > 0)
.WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>()
.WithTools<HandoffMcpTools>()
.WithTools<SessionHandoffMcpTools>()
.WithTools<ListHandlerMcpTools>()
.WithTools<AppSettingsMcpTools>()
.WithTools<TaskWaitMcpTools>()
@@ -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.</summary>
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
/// launch, so a closed/aborted session can be resumed next time. A non-null
/// <paramref name="paneId"/> appends the Mission Control handoff hint (see
/// <see cref="PaneSessionArgsAsync"/>).</summary>
public async Task<LaunchSpec> 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<string> 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
/// <summary>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.</summary>
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
/// directory doesn't exist. A non-null <paramref name="paneId"/> pre-assigns a session id and
/// appends the Mission Control handoff hint.</summary>
public async Task<LaunchSpec> 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<string>(), EffortFor(settings, settings.DefaultModel)), env);
var args = WithEffort(Array.Empty<string>(), 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);
}
/// <summary>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 <see cref="PaneSessionDir"/>) 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.</summary>
public async Task<LaunchSpec> 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<string>(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<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env);
}
/// <summary>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.</summary>
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));
}
/// <summary>Name of the per-tile MCP entry that carries the tile's identity in its headers.</summary>
public const string PaneMcpServerName = "claudedo_pane";
// `--mcp-config <file> --append-system-prompt-file <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<IReadOnlyList<string>> PaneSessionArgsAsync(string paneId, string sessionId, CancellationToken ct)
{
if (_cfg.ExternalMcpPort <= 0) return [];
var dir = PaneSessionDir(paneId);
Directory.CreateDirectory(dir);
var headers = new Dictionary<string, string>
{
[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<string, object>
{
["mcpServers"] = new Dictionary<string, object>
{
[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,
+5 -1
View File
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, string>? SessionHandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? 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<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds);
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> 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<string> 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<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(cwd, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync(
@@ -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;
@@ -490,7 +490,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
public override Task<LaunchSpec> 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<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
{
AdHocPaneId ??= paneId;
return base.GetAdHocLaunchSpecAsync(directory, paneId, ct);
}
public override Task<LaunchSpec> 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()
{
@@ -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",
@@ -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<InvalidOperationException>(() =>
BuildSut(Request(paneId)).HandoffSession("brief", CancellationToken.None));
Assert.Empty(_hubContext.Proxy.Calls);
}
[Fact]
public async Task HandoffSession_EmptyBrief_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
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)));
}
}
@@ -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<InvalidOperationException>(
() => 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<string> _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<WorktreeManager>.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<InvalidOperationException>(
() => BuildService().BuildHandoffAsync(NewPaneId(), _tempDir, outside, null, CancellationToken.None));
}
// ── Merge helper ──
@@ -1482,7 +1611,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var specs = new List<LaunchSpec>
{
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None),
mergeHelperSpec,
handoffSpec,
svc.BuildPlanningStart(planningStartCtx),