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:
Mika Kuns
2026-09-28 11:57:37 +02:00
parent e0a1448f44
commit 08d1797ead
16 changed files with 598 additions and 35 deletions
@@ -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,