feat(ui): Mission Control Recent Sessions mit verschachtelten Handoffs

Neue Tabelle mission_control_sessions (Migration AddMissionControlSessions), befuellt vom
InteractiveLaunchSpecService bei Task-, Ad-hoc- und Handoff-Kacheln. Flyout im Mission-Control-Header
listet die letzten 14 Tage / 50 Sessions, Handoffs eingerueckt unter ihrer Ursprungs-Session, mit
Fortsetzen (--resume im gespeicherten cwd) und Brief anzeigen.
This commit is contained in:
Mika Kuns
2026-09-28 12:11:21 +02:00
parent 216620c52e
commit d3db4455fa
23 changed files with 1630 additions and 2 deletions
+13 -1
View File
@@ -53,13 +53,25 @@ public sealed class SessionHandoffMcpTools
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";
var header = fromSession.Length == 0 ? "" : $"{HandedOffFromPrefix}{fromSession}`.\n\n";
await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken);
await _broadcaster.SessionHandoffRequested(paneId, briefPath);
return new SessionHandoffResult(true, briefPath);
}
/// <summary>Start of a brief's first line; the handing-off session id and a closing backtick follow.</summary>
private const string HandedOffFromPrefix = "Handed off from session `";
/// <summary>The session id a brief was handed off from, or null when its first line carries none.</summary>
public static string? ReadHandedOffFrom(string briefPath)
{
var first = File.ReadLines(briefPath).FirstOrDefault();
if (first is null || !first.StartsWith(HandedOffFromPrefix, StringComparison.Ordinal)) return null;
var end = first.IndexOf('`', HandedOffFromPrefix.Length);
return end > HandedOffFromPrefix.Length ? first[HandedOffFromPrefix.Length..end] : null;
}
/// <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(
+8
View File
@@ -798,6 +798,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
});
/// <summary>Launch spec resuming a recorded Mission Control session ("Recent sessions") in a new tile.</summary>
public Task<LaunchSpec> GetResumeSessionLaunchSpec(string sessionId, string paneId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildResumeAsync(sessionId, paneId, Context.ConnectionAborted);
});
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
// and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is
@@ -203,7 +203,13 @@ public sealed class InteractiveLaunchSpecService
}
if (paneId is not null)
{
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = sessionDir, TaskId = taskId, Title = task.Title,
}, ct);
}
// 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
@@ -288,6 +294,10 @@ public sealed class InteractiveLaunchSpecService
// 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];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = directory, Title = FolderTitle(directory),
}, ct);
}
return new LaunchSpec(directory, resolvedClaude, args, env);
}
@@ -316,14 +326,28 @@ public sealed class InteractiveLaunchSpecService
var sessionId = Guid.NewGuid().ToString();
var model = settings.DefaultModel;
TaskEntity? task = null;
if (taskId is not null)
{
var taskRepo = new TaskRepository(ctx);
var task = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}");
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);
}
var sessions = new MissionControlSessionRepository(ctx);
var fromSession = SessionHandoffMcpTools.ReadHandedOffFrom(briefPath);
var predecessor = fromSession is null ? null : await sessions.GetAsync(fromSession, ct);
await sessions.RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId,
Cwd = cwd,
TaskId = taskId ?? predecessor?.TaskId,
Title = predecessor?.Title ?? task?.Title ?? FolderTitle(cwd),
PredecessorSessionId = fromSession,
BriefPath = briefPath,
}, 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))
{
@@ -342,6 +366,37 @@ public sealed class InteractiveLaunchSpecService
return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env);
}
/// <summary>Builds the LaunchSpec that resumes a recorded Mission Control session (`--resume`) in
/// its own cwd, with the handoff hint so the resumed session can hand off again. Throws
/// KeyNotFoundException for an unrecorded session, InvalidOperationException for a missing cwd.</summary>
public async Task<LaunchSpec> BuildResumeAsync(string sessionId, string paneId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var session = await new MissionControlSessionRepository(ctx).GetAsync(sessionId, ct)
?? throw new KeyNotFoundException($"Session not found: {sessionId}");
if (!Directory.Exists(session.Cwd))
throw new InvalidOperationException($"Directory does not exist: {session.Cwd}");
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
IReadOnlyList<string> args =
[
.. await PaneSessionArgsAsync(paneId, sessionId, ct),
.. WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), EffortFor(settings, settings.DefaultModel)),
];
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(session.Cwd, resolvedClaude, args, env);
}
// Same title an ad-hoc tile shows: the folder name, or the whole path for a drive root.
private static string FolderTitle(string directory) =>
Path.GetFileName(directory.TrimEnd('\\', '/')) is { Length: > 0 } name ? name : directory;
/// <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>