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.
91 lines
4.7 KiB
C#
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());
|
|
}
|