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 ? "" : $"{HandedOffFromPrefix}{fromSession}`.\n\n"; await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken); await _broadcaster.SessionHandoffRequested(paneId, briefPath); return new SessionHandoffResult(true, briefPath); } /// Start of a brief's first line; the handing-off session id and a closing backtick follow. private const string HandedOffFromPrefix = "Handed off from session `"; /// The session id a brief was handed off from, or null when its first line carries none. 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; } /// 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()); }