chore(claude-do): Mission Control: allgemeiner Session-Handoff an eine frische
## Ziel Jede Mission-Control-Session (Ad-hoc UND Task-Session) soll ihre Arbeit an eine frische Session uebergeben koennen: "gib das ab an eine neue Session". Die alte Kachel wird geschlossen, an derselben Position oeffnet eine frische Session im selben cwd, die mit einem Brief startet, den die abgebende Session selbst geschrieben hat. Motivation: eine lange Session mit vollem/verrottetem Kontex ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
This commit is contained in:
@@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
||||
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
|
||||
/// <summary>A Mission Control session called handoff_session: (paneId, briefPath). The UI replaces
|
||||
/// the tile whose IdentityKey is paneId with a fresh session starting from the brief.</summary>
|
||||
event Action<string, string>? SessionHandoffRequestedEvent;
|
||||
|
||||
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
|
||||
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
|
||||
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||
@@ -133,8 +137,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// in a task's worktree (same worktree prep as an autonomous run).</summary>
|
||||
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||
/// no task, no worktree.</summary>
|
||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
|
||||
/// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint.</summary>
|
||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
||||
Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
|
||||
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
|
||||
|
||||
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
public event Action<string, string>? SessionHandoffRequestedEvent;
|
||||
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||
public event Action<string, string>? HandlerUsageWarningEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
@@ -160,6 +161,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("SessionHandoffRequested", (paneId, briefPath) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath));
|
||||
});
|
||||
|
||||
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
|
||||
@@ -620,8 +626,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
|
||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, paneId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
public event Action<string, int>? SessionDiedAfterStartup;
|
||||
|
||||
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
|
||||
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
|
||||
/// (no task, never deduped) — its host sets <see cref="IdentityKey"/>, the tile's paneId.
|
||||
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
|
||||
/// so the tile — and its starting spinner — is visible while the launch spec is still being
|
||||
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
|
||||
@@ -132,11 +132,17 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
|
||||
public void Start() => _ = StartAsync();
|
||||
|
||||
/// <summary>Working directory of the launched session, known once the launch spec resolved —
|
||||
/// a handoff reopens the fresh session there.</summary>
|
||||
public string? Cwd { get; private set; }
|
||||
|
||||
private async System.Threading.Tasks.Task StartAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Terminal.Start(await _descriptorFactory());
|
||||
var descriptor = await _descriptorFactory();
|
||||
Cwd = descriptor.Cwd;
|
||||
Terminal.Start(descriptor);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -144,12 +150,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
|
||||
public static ConPtyPaneViewModel CreateAdHoc(
|
||||
string displayTitle,
|
||||
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
||||
=> new(null, displayTitle, descriptorFactory);
|
||||
|
||||
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
|
||||
|
||||
@@ -108,6 +108,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
||||
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
||||
_worker.SessionHandoffRequestedEvent += OpenSessionHandoff;
|
||||
|
||||
_onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
|
||||
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
|
||||
@@ -322,11 +323,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
||||
if (string.IsNullOrEmpty(title)) title = directory;
|
||||
|
||||
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
||||
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
|
||||
// The key is minted here, not in the pane, because the launch spec needs it as the paneId.
|
||||
var paneId = Guid.NewGuid().ToString("n");
|
||||
AddConPtyPane(new ConPtyPaneViewModel(null, title,
|
||||
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory, paneId))) { IdentityKey = paneId });
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>A session called handoff_session: replaces its tile (same slot, same IdentityKey, so
|
||||
/// same colour) with a fresh session in the same cwd that starts from the brief. No matching tile
|
||||
/// (closed meanwhile, or another app instance) = nothing to replace.</summary>
|
||||
internal void OpenSessionHandoff(string paneId, string briefPath)
|
||||
{
|
||||
if (ConPtySessions.FirstOrDefault(s => s.IdentityKey == paneId) is not { Cwd: { } cwd } outgoing) return;
|
||||
OpenRequested?.Invoke();
|
||||
|
||||
var slot = ConPtySessions.IndexOf(outgoing);
|
||||
var taskId = outgoing.TaskId;
|
||||
var title = outgoing.DisplayTitle;
|
||||
CloseConPtySession(outgoing);
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId)))
|
||||
{ IdentityKey = paneId }, slot);
|
||||
}
|
||||
|
||||
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
|
||||
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
||||
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
||||
@@ -615,6 +636,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
_worker.HandoffRequestedEvent -= _onHandoffRequested;
|
||||
_worker.SessionHandoffRequestedEvent -= OpenSessionHandoff;
|
||||
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
|
||||
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
|
||||
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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 ? "" : $"Handed off from session `{fromSession}`.\n\n";
|
||||
await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken);
|
||||
|
||||
await _broadcaster.SessionHandoffRequested(paneId, briefPath);
|
||||
return new SessionHandoffResult(true, briefPath);
|
||||
}
|
||||
|
||||
/// <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());
|
||||
}
|
||||
@@ -37,6 +37,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
||||
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
|
||||
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
|
||||
|
||||
/// <summary>A Mission Control session called handoff_session -- the UI replaces the tile whose
|
||||
/// IdentityKey is paneId with a fresh session that starts from the brief.</summary>
|
||||
public Task SessionHandoffRequested(string paneId, string briefPath) =>
|
||||
_hub.Clients.All.SendAsync("SessionHandoffRequested", paneId, briefPath);
|
||||
|
||||
// start_list_handler (External MCP) -- the App creates the handler task and opens the triage
|
||||
// tile, exactly like the "Let Claude handle it" button.
|
||||
public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) =>
|
||||
|
||||
@@ -776,16 +776,26 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
_broadcaster.WorktreeUpdated(taskId);
|
||||
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
|
||||
// A task tile's paneId is its task id (ConPtyPaneViewModel.IdentityKey).
|
||||
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted, paneId: taskId);
|
||||
});
|
||||
|
||||
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||
// no task, no worktree, no session-skills seeding.
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
|
||||
// no task, no worktree, no session-skills seeding. paneId is the tile's IdentityKey, rendered
|
||||
// into the handoff hint.
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory, string paneId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
|
||||
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
||||
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
|
||||
@@ -375,6 +375,8 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
||||
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
||||
externalBuilder.Services.AddScoped<HandoffMcpTools>();
|
||||
externalBuilder.Services.AddScoped<SessionHandoffMcpTools>();
|
||||
externalBuilder.Services.AddHttpContextAccessor();
|
||||
externalBuilder.Services.AddScoped<ListHandlerMcpTools>();
|
||||
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
||||
@@ -385,7 +387,9 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddScoped<NotesMcpTools>();
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport(o => o.Stateless = true)
|
||||
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
||||
.WithRequestFilters(f => f
|
||||
.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)
|
||||
.AddListToolsFilter(SessionHandoffMcpTools.FilterByPane))
|
||||
.WithTools<ExternalMcpService>()
|
||||
.WithTools<MergeMcpTools>()
|
||||
.WithTools<WorktreeMcpTools>()
|
||||
@@ -396,6 +400,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
.WithTools<AgentMcpTools>()
|
||||
.WithTools<LifecycleMcpTools>()
|
||||
.WithTools<HandoffMcpTools>()
|
||||
.WithTools<SessionHandoffMcpTools>()
|
||||
.WithTools<ListHandlerMcpTools>()
|
||||
.WithTools<AppSettingsMcpTools>()
|
||||
.WithTools<TaskWaitMcpTools>()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
@@ -43,6 +44,7 @@ public sealed class InteractiveLaunchSpecService
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly GitService _git;
|
||||
private readonly string _claudePath;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly HandoffRoundTracker _handoffRounds;
|
||||
private readonly HandlerRoundState _roundState;
|
||||
|
||||
@@ -67,6 +69,7 @@ public sealed class InteractiveLaunchSpecService
|
||||
_wtManager = wtManager;
|
||||
_git = git;
|
||||
_claudePath = cfg.ClaudeBin;
|
||||
_cfg = cfg;
|
||||
_handoffRounds = handoffRounds;
|
||||
_roundState = roundState;
|
||||
}
|
||||
@@ -81,8 +84,10 @@ public sealed class InteractiveLaunchSpecService
|
||||
/// it has one, else the latest autonomous run's session; a task that has never run either
|
||||
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
|
||||
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
|
||||
/// launch, so a closed/aborted session can be resumed next time.</summary>
|
||||
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
|
||||
/// launch, so a closed/aborted session can be resumed next time. A non-null
|
||||
/// <paramref name="paneId"/> appends the Mission Control handoff hint (see
|
||||
/// <see cref="PaneSessionArgsAsync"/>).</summary>
|
||||
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct, string? paneId = null)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
@@ -184,17 +189,22 @@ public sealed class InteractiveLaunchSpecService
|
||||
// persisted HERE, before launch, so a closed/aborted session -- even one that never got
|
||||
// past startup -- still leaves an id the next open can resume.
|
||||
IReadOnlyList<string> args;
|
||||
string sessionId;
|
||||
if (resumeSessionId is { Length: > 0 })
|
||||
{
|
||||
sessionId = resumeSessionId;
|
||||
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
|
||||
}
|
||||
else
|
||||
{
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
sessionId = Guid.NewGuid().ToString();
|
||||
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
|
||||
args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
|
||||
}
|
||||
|
||||
if (paneId is not null)
|
||||
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args];
|
||||
|
||||
// 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
|
||||
// it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
|
||||
@@ -253,8 +263,9 @@ public sealed class InteractiveLaunchSpecService
|
||||
|
||||
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
|
||||
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
|
||||
/// directory doesn't exist.</summary>
|
||||
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
||||
/// directory doesn't exist. A non-null <paramref name="paneId"/> pre-assigns a session id and
|
||||
/// appends the Mission Control handoff hint.</summary>
|
||||
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, string? paneId, CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"Directory does not exist: {directory}");
|
||||
@@ -271,9 +282,117 @@ public sealed class InteractiveLaunchSpecService
|
||||
};
|
||||
|
||||
// No task and no list here — the global default model's preset decides the effort.
|
||||
return new LaunchSpec(
|
||||
directory, resolvedClaude,
|
||||
WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env);
|
||||
var args = WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel));
|
||||
if (paneId is not null)
|
||||
{
|
||||
// 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];
|
||||
}
|
||||
return new LaunchSpec(directory, resolvedClaude, args, env);
|
||||
}
|
||||
|
||||
/// <summary>Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via
|
||||
/// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool
|
||||
/// under <see cref="PaneSessionDir"/>) as the single-line kickoff, and the handoff hint again so
|
||||
/// the chain can continue. With a taskId the new session id becomes the task's
|
||||
/// InteractiveSessionId, so reopening the task resumes the handed-off session. Throws
|
||||
/// InvalidOperationException for a missing cwd or a brief outside the pane's own dir.</summary>
|
||||
public async Task<LaunchSpec> BuildHandoffAsync(
|
||||
string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(cwd))
|
||||
throw new InvalidOperationException($"Directory does not exist: {cwd}");
|
||||
briefPath = Path.GetFullPath(briefPath);
|
||||
if (!briefPath.StartsWith(PaneSessionDir(paneId) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
|| !File.Exists(briefPath))
|
||||
throw new InvalidOperationException($"Handoff brief not found: {briefPath}");
|
||||
|
||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
|
||||
var model = settings.DefaultModel;
|
||||
if (taskId is not null)
|
||||
{
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
var 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);
|
||||
}
|
||||
|
||||
// --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))
|
||||
{
|
||||
"--add-dir", Path.GetDirectoryName(briefPath)!,
|
||||
"--effort", EffortFor(settings, model),
|
||||
"--session-id", sessionId,
|
||||
$"Read the file {briefPath} first. It is the handoff brief from the session that worked here " +
|
||||
"before you. After reading it, continue the work as it describes.",
|
||||
};
|
||||
|
||||
var env = new Dictionary<string, string>
|
||||
{
|
||||
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||
};
|
||||
|
||||
return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public static string PaneSessionDir(string paneId)
|
||||
{
|
||||
if (!Guid.TryParse(paneId, out _))
|
||||
throw new InvalidOperationException($"Invalid paneId: {paneId}");
|
||||
return Path.GetFullPath(Path.Combine(Paths.AppDataRoot(), "pane-sessions", paneId));
|
||||
}
|
||||
|
||||
/// <summary>Name of the per-tile MCP entry that carries the tile's identity in its headers.</summary>
|
||||
public const string PaneMcpServerName = "claudedo_pane";
|
||||
|
||||
// `--mcp-config <file> --append-system-prompt-file <file>` giving the session a `claudedo_pane`
|
||||
// entry whose headers name this tile and session, so handoff_session needs no id from the model.
|
||||
// Empty without the external MCP -- there is nothing to hand off through.
|
||||
private async Task<IReadOnlyList<string>> PaneSessionArgsAsync(string paneId, string sessionId, CancellationToken ct)
|
||||
{
|
||||
if (_cfg.ExternalMcpPort <= 0) return [];
|
||||
|
||||
var dir = PaneSessionDir(paneId);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
[SessionHandoffMcpTools.PaneHeader] = paneId,
|
||||
[SessionHandoffMcpTools.SessionHeader] = sessionId,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(_cfg.ExternalMcpApiKey))
|
||||
headers["X-ClaudeDo-Key"] = _cfg.ExternalMcpApiKey;
|
||||
var mcpConfigPath = Path.Combine(dir, "mcp.json");
|
||||
await File.WriteAllTextAsync(mcpConfigPath, JsonSerializer.Serialize(new Dictionary<string, object>
|
||||
{
|
||||
["mcpServers"] = new Dictionary<string, object>
|
||||
{
|
||||
[PaneMcpServerName] = new { type = "http", url = $"http://127.0.0.1:{_cfg.ExternalMcpPort}/mcp", headers },
|
||||
},
|
||||
}), ct);
|
||||
|
||||
var hintPath = Path.Combine(dir, "handoff-hint.md");
|
||||
await File.WriteAllTextAsync(hintPath,
|
||||
$"""
|
||||
You are running inside a ClaudeDo Mission Control tile. Only when the user asks you to hand off
|
||||
to a fresh session, call mcp__{PaneMcpServerName}__{SessionHandoffMcpTools.ToolName} with a brief.
|
||||
The brief is the ONLY context the fresh session gets, so make it self-contained: the goal, what
|
||||
is done, decisions made and why, the current state (branch, uncommitted changes, failing tests),
|
||||
what comes next, open questions, and the relevant file paths. This tile closes and a fresh
|
||||
session opens in its place right after the call -- end your turn immediately.
|
||||
""", ct);
|
||||
|
||||
return ["--mcp-config", mcpConfigPath, "--append-system-prompt-file", hintPath];
|
||||
}
|
||||
|
||||
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
|
||||
|
||||
Reference in New Issue
Block a user