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());
}