Files
ClaudeDo/src/ClaudeDo.Worker/External/SessionHandoffMcpTools.cs
T
Mika Kuns d3db4455fa 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.
2026-09-28 12:11:21 +02:00

91 lines
4.7 KiB
C#

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 ? "" : $"{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(
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());
}