chore(claude-do): merge Mission Control: Recent Sessions mit verschachtelten Handoff

ClaudeDo-Task: e51c2b8a-614b-4e4f-8e93-6f90a7ba9960
This commit is contained in:
Mika Kuns
2026-09-28 12:24:39 +02:00
23 changed files with 1630 additions and 2 deletions
+15
View File
@@ -124,6 +124,21 @@ with the same `IdentityKey` via `GetSessionHandoffLaunchSpec` → `BuildHandoffA
tile, the new session id replaces `InteractiveSessionId`, so reopening the task resumes the
handed-off session. The old transcript remains under `~/.claude/projects/`.
## Recent sessions
Every task, ad-hoc and handoff tile launch (only those — `paneId != null`) records its session in
`mission_control_sessions` from inside `InteractiveLaunchSpecService`, which is where the session id
is known. A handoff's predecessor comes from the brief's first line
(`SessionHandoffMcpTools.ReadHandedOffFrom`), and it inherits the predecessor's title and task.
The clock button in the Mission Control header opens a flyout (`RefreshRecentSessionsAsync`, last 14
days / 50 rows, read straight from the DB). `NestHandoffs` groups each chain under its origin.
- **Resume**: a task's *current* `InteractiveSessionId` reopens the task tile (deduped by TaskId).
Any other session gets a fresh ad-hoc tile via `GetResumeSessionLaunchSpec` → `BuildResumeAsync`
(`--resume <id>` in the recorded cwd, handoff hint included).
- **Brief**: `ShellOpen.Path(brief.md)`, which opens the file in the default editor.
- Not guarded: resuming a session that is still live in another ad-hoc tile, since panes don't
track their session id.
## List handler ("Let Claude handle it")
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
+2
View File
@@ -24,6 +24,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments`
- **TaskDependencyEntity** — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table `task_dependencies`. Both FKs are `ON DELETE CASCADE`, which is what the old single `depends_on_task_id` column got from `ON DELETE SET NULL`: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written **only** by `TaskStateService.SetDependsOnAsync`.
- **MissionControlSessionEntity** — SessionId (PK), Cwd, TaskId (no FK), Title, StartedAt, PredecessorSessionId, BriefPath → `mission_control_sessions`. Index behind Mission Control's "Recent sessions" flyout; written only by `InteractiveLaunchSpecService` (task/ad-hoc/handoff tile launches), insert-if-absent → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
### AppSettingsEntity
@@ -120,6 +121,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
- `task_attachments`
- `task_dependencies`
- `list_folders`
- `mission_control_sessions`
Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into
a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now,
+1
View File
@@ -54,6 +54,7 @@ public class ClaudeDoDbContext : DbContext
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
public DbSet<MissionControlSessionEntity> MissionControlSessions => Set<MissionControlSessionEntity>();
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
@@ -0,0 +1,22 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class MissionControlSessionEntityConfiguration : IEntityTypeConfiguration<MissionControlSessionEntity>
{
public void Configure(EntityTypeBuilder<MissionControlSessionEntity> builder)
{
builder.ToTable("mission_control_sessions");
builder.HasKey(s => s.SessionId);
builder.Property(s => s.SessionId).HasColumnName("session_id").ValueGeneratedNever();
builder.Property(s => s.Cwd).HasColumnName("cwd").IsRequired();
builder.Property(s => s.TaskId).HasColumnName("task_id");
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
builder.Property(s => s.StartedAt).HasColumnName("started_at").IsRequired();
builder.Property(s => s.PredecessorSessionId).HasColumnName("predecessor_session_id");
builder.Property(s => s.BriefPath).HasColumnName("brief_path");
builder.HasIndex(s => s.StartedAt);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddMissionControlSessions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "mission_control_sessions",
columns: table => new
{
session_id = table.Column<string>(type: "TEXT", nullable: false),
cwd = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: true),
title = table.Column<string>(type: "TEXT", nullable: false),
started_at = table.Column<DateTime>(type: "TEXT", nullable: false),
predecessor_session_id = table.Column<string>(type: "TEXT", nullable: true),
brief_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_mission_control_sessions", x => x.session_id);
});
migrationBuilder.CreateIndex(
name: "IX_mission_control_sessions_started_at",
table: "mission_control_sessions",
column: "started_at");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mission_control_sessions");
}
}
}
@@ -403,6 +403,45 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("list_folders", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b =>
{
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<string>("BriefPath")
.HasColumnType("TEXT")
.HasColumnName("brief_path");
b.Property<string>("Cwd")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cwd");
b.Property<string>("PredecessorSessionId")
.HasColumnType("TEXT")
.HasColumnName("predecessor_session_id");
b.Property<DateTime>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("SessionId");
b.HasIndex("StartedAt");
b.ToTable("mission_control_sessions", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
@@ -0,0 +1,27 @@
namespace ClaudeDo.Data.Models;
/// <summary>One claude session started in a Mission Control tile (ad-hoc, task or handoff) — the
/// index behind "Recent sessions". The transcript itself stays with Claude Code.</summary>
public sealed class MissionControlSessionEntity
{
/// <summary>The claude session id (`--session-id` / `--resume`).</summary>
public string SessionId { get; init; } = string.Empty;
/// <summary>Working directory the session ran in — a resume must start there.</summary>
public string Cwd { get; set; } = string.Empty;
/// <summary>Task the tile belonged to; null for an ad-hoc tile. No FK: the row outlives the task.</summary>
public string? TaskId { get; set; }
/// <summary>Tile title at start (task title or folder name; a handoff inherits its predecessor's).</summary>
public string Title { get; set; } = string.Empty;
/// <summary>UTC time the session was first launched.</summary>
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
/// <summary>Session this one was handed off from; null unless it is a handoff session.</summary>
public string? PredecessorSessionId { get; set; }
/// <summary>brief.md the session started from; non-null exactly for handoff sessions.</summary>
public string? BriefPath { get; set; }
}
@@ -0,0 +1,34 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
/// <summary>Reads and writes <c>mission_control_sessions</c>.</summary>
public sealed class MissionControlSessionRepository
{
private readonly ClaudeDoDbContext _context;
public MissionControlSessionRepository(ClaudeDoDbContext context) => _context = context;
/// <summary>Inserts the session unless it is already recorded — a resumed task session keeps
/// its original row and start time.</summary>
public async Task RecordAsync(MissionControlSessionEntity session, CancellationToken ct = default)
{
if (await _context.MissionControlSessions.AnyAsync(s => s.SessionId == session.SessionId, ct)) return;
_context.MissionControlSessions.Add(session);
await _context.SaveChangesAsync(ct);
}
/// <summary>The recorded session, or null.</summary>
public async Task<MissionControlSessionEntity?> GetAsync(string sessionId, CancellationToken ct = default) =>
await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct);
/// <summary>Sessions started since <paramref name="sinceUtc"/>, newest first, at most <paramref name="max"/>.</summary>
public async Task<IReadOnlyList<MissionControlSessionEntity>> ListRecentAsync(
DateTime sinceUtc, int max, CancellationToken ct = default) =>
await _context.MissionControlSessions.AsNoTracking()
.Where(s => s.StartedAt >= sinceUtc)
.OrderByDescending(s => s.StartedAt)
.Take(max)
.ToListAsync(ct);
}
@@ -378,6 +378,12 @@
"openSession": "Sitzung öffnen",
"homeFolder": "Home (Schnellsitzung)",
"browseForFolder": "Ordner wählen…",
"recentSessions": "Letzte Sitzungen",
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
"resumeSession": "Fortsetzen",
"showBrief": "Brief",
"handoffSession": "Übergabe",
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
"focusMode": "Fokus",
"overviewMode": "Übersicht",
"closeSession": "Sitzung hart beenden",
@@ -378,6 +378,12 @@
"openSession": "Open session",
"homeFolder": "Home (quick session)",
"browseForFolder": "Choose folder…",
"recentSessions": "Recent sessions",
"recentSessionsEmpty": "No sessions in the last 14 days",
"resumeSession": "Resume",
"showBrief": "Brief",
"handoffSession": "Handoff",
"briefOpenFailed": "Couldn't open the brief: {0}",
"focusMode": "Focus",
"overviewMode": "Overview",
"closeSession": "Kill session",
+1
View File
@@ -158,6 +158,7 @@
<Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness>
<Thickness x:Key="IslandBodyPadding">14</Thickness>
<Thickness x:Key="WindowBodyPadding">14</Thickness>
<Thickness x:Key="NestedRowIndent">18,0,0,0</Thickness> <!-- SpaceXl: a child row under its parent -->
<!-- ============================================================ -->
<!-- CORNERS & BORDERS -->
@@ -141,6 +141,8 @@ public interface IWorkerClient : INotifyPropertyChanged
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 resuming a recorded Mission Control session in its cwd, as tile paneId.</summary>
Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, 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
+3
View File
@@ -632,6 +632,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
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> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetResumeSessionLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetResumeSessionLaunchSpec", sessionId, paneId, 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));
@@ -5,6 +5,8 @@ using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.MissionControl;
@@ -75,6 +77,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
/// <summary>"Recent sessions" flyout rows: each chain's origin session, its handoffs right below it.</summary>
public ObservableCollection<RecentSessionViewModel> RecentSessions { get; } = new();
/// <summary>False shows the flyout's empty-state line.</summary>
public bool HasRecentSessions => RecentSessions.Count > 0;
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
@@ -215,6 +223,84 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
/// <summary>Rebuilds <see cref="RecentSessions"/> from the last 14 days (max 50 sessions); runs on
/// every flyout open.</summary>
internal async System.Threading.Tasks.Task RefreshRecentSessionsAsync()
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var sessions = await new MissionControlSessionRepository(ctx).ListRecentAsync(DateTime.UtcNow.AddDays(-14), 50);
var ids = sessions.Select(s => s.SessionId).ToList();
// A task's current session resumes through its task tile, so it stays deduped by TaskId.
var taskCurrent = (await ctx.Tasks.AsNoTracking()
.Where(t => t.InteractiveSessionId != null && ids.Contains(t.InteractiveSessionId))
.Select(t => t.InteractiveSessionId!)
.ToListAsync()).ToHashSet();
RecentSessions.Clear();
foreach (var (s, isHandoff) in NestHandoffs(sessions))
{
var time = DateTime.SpecifyKind(s.StartedAt, DateTimeKind.Utc).ToLocalTime().ToString("g");
var briefPath = s.BriefPath;
RecentSessions.Add(new RecentSessionViewModel
{
Title = s.Title,
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
IsHandoff = isHandoff,
ResumeCommand = new RelayCommand(() => ResumeSession(s, taskCurrent.Contains(s.SessionId))),
ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)),
});
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
OnPropertyChanged(nameof(HasRecentSessions));
}
/// <summary>Orders newest-first sessions into chains: each origin session (no recorded predecessor
/// in the set) followed by every session handed off from it, directly or indirectly, oldest
/// first. Chains are ordered by their newest member.</summary>
internal static IEnumerable<(MissionControlSessionEntity Session, bool IsHandoff)> NestHandoffs(
IReadOnlyList<MissionControlSessionEntity> newestFirst)
{
var byId = newestFirst.ToDictionary(s => s.SessionId);
string RootOf(MissionControlSessionEntity s)
{
var seen = new HashSet<string>();
while (s.PredecessorSessionId is { } p && byId.TryGetValue(p, out var prev) && seen.Add(s.SessionId))
s = prev;
return s.SessionId;
}
return newestFirst
.GroupBy(RootOf)
.SelectMany(chain => chain
.OrderBy(s => s.SessionId != chain.Key)
.ThenBy(s => s.StartedAt)
.Select(s => (s, s.SessionId != chain.Key)));
}
// A task's current session reopens its task tile (deduped, same worktree guards); any other
// session gets a fresh ad-hoc tile resuming it in its recorded cwd.
private void ResumeSession(MissionControlSessionEntity session, bool isTaskCurrent)
{
if (session.TaskId is { } taskId && isTaskCurrent)
{
_ = OpenConPtySessionAsync(taskId);
return;
}
var paneId = Guid.NewGuid().ToString("n");
var sessionId = session.SessionId;
AddConPtyPane(new ConPtyPaneViewModel(null, session.Title,
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId });
}
private void ShowBrief(string briefPath)
{
var (ok, error) = ShellOpen.Path(briefPath);
if (!ok) ErrorReported?.Invoke(Loc.T("missionControl.briefOpenFailed", error ?? briefPath));
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here
@@ -674,6 +760,20 @@ public sealed class QueuedTaskViewModel
public IRelayCommand? OpenSessionCommand { get; init; }
}
/// <summary>One row in the "Recent sessions" flyout.</summary>
public sealed class RecentSessionViewModel
{
public required string Title { get; init; }
/// <summary>Start time plus cwd (origin) or the handoff label (handoff).</summary>
public required string Meta { get; init; }
/// <summary>Indents the row under its chain's origin session.</summary>
public bool IsHandoff { get; init; }
public required IRelayCommand ResumeCommand { get; init; }
/// <summary>Opens the session's brief.md; null for a session that did not start from one.</summary>
public IRelayCommand? ShowBriefCommand { get; init; }
public bool HasBrief => ShowBriefCommand is not null;
}
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel
{
@@ -48,6 +48,60 @@
</MenuFlyout>
</Button.Flyout>
</Button>
<Button x:Name="RecentSessionsButton" Classes="icon-btn"
ToolTip.Tip="{loc:Tr missionControl.recentSessions}">
<PathIcon Data="{StaticResource Icon.Clock}" Width="15" Height="15"/>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard" Opening="OnRecentSessionsFlyoutOpening">
<StackPanel Width="380" Spacing="8" Margin="4">
<TextBlock Classes="eyebrow"
Text="{loc:Tr missionControl.recentSessions}"
Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.recentSessionsEmpty}"
IsVisible="{Binding !HasRecentSessions}"
Foreground="{DynamicResource TextMuteBrush}" />
<ScrollViewer MaxHeight="420">
<ItemsControl x:Name="RecentSessionsList" ItemsSource="{Binding RecentSessions}">
<ItemsControl.Styles>
<Style Selector="Grid.handoff">
<Setter Property="Margin" Value="{StaticResource NestedRowIndent}" />
</Style>
</ItemsControl.Styles>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:RecentSessionViewModel">
<Grid ColumnDefinitions="*,Auto" Classes.handoff="{Binding IsHandoff}">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,4" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta"
Text="{Binding Meta}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Meta}"
Foreground="{DynamicResource TextMuteBrush}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Center">
<Button Classes="btn"
Content="{loc:Tr missionControl.showBrief}"
Command="{Binding ShowBriefCommand}"
IsVisible="{Binding HasBrief}" />
<Button Classes="btn"
Content="{loc:Tr missionControl.resumeSession}"
Command="{Binding ResumeCommand}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
<Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}"
ToolTip.Tip="{Binding LayoutToggleLabel}">
@@ -29,10 +29,14 @@ public partial class MissionControlView : UserControl
InitializeComponent();
DataContextChanged += OnDataContextChanged;
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
// Resume/Brief buttons don't close a plain Flyout on their own.
RecentSessionsList.AddHandler(Button.ClickEvent, (_, _) => RecentSessionsButton.Flyout?.Hide());
}
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
private void OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void BrowseForSessionAsync()
{
+13 -1
View File
@@ -53,13 +53,25 @@ public sealed class SessionHandoffMcpTools
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";
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(
+8
View File
@@ -798,6 +798,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
});
/// <summary>Launch spec resuming a recorded Mission Control session ("Recent sessions") in a new tile.</summary>
public Task<LaunchSpec> GetResumeSessionLaunchSpec(string sessionId, string paneId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildResumeAsync(sessionId, paneId, Context.ConnectionAborted);
});
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
// and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is
@@ -203,7 +203,13 @@ public sealed class InteractiveLaunchSpecService
}
if (paneId is not null)
{
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = sessionDir, TaskId = taskId, Title = task.Title,
}, ct);
}
// 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
@@ -288,6 +294,10 @@ public sealed class InteractiveLaunchSpecService
// 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];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = directory, Title = FolderTitle(directory),
}, ct);
}
return new LaunchSpec(directory, resolvedClaude, args, env);
}
@@ -316,14 +326,28 @@ public sealed class InteractiveLaunchSpecService
var sessionId = Guid.NewGuid().ToString();
var model = settings.DefaultModel;
TaskEntity? task = null;
if (taskId is not null)
{
var taskRepo = new TaskRepository(ctx);
var task = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}");
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);
}
var sessions = new MissionControlSessionRepository(ctx);
var fromSession = SessionHandoffMcpTools.ReadHandedOffFrom(briefPath);
var predecessor = fromSession is null ? null : await sessions.GetAsync(fromSession, ct);
await sessions.RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId,
Cwd = cwd,
TaskId = taskId ?? predecessor?.TaskId,
Title = predecessor?.Title ?? task?.Title ?? FolderTitle(cwd),
PredecessorSessionId = fromSession,
BriefPath = briefPath,
}, 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))
{
@@ -342,6 +366,37 @@ public sealed class InteractiveLaunchSpecService
return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env);
}
/// <summary>Builds the LaunchSpec that resumes a recorded Mission Control session (`--resume`) in
/// its own cwd, with the handoff hint so the resumed session can hand off again. Throws
/// KeyNotFoundException for an unrecorded session, InvalidOperationException for a missing cwd.</summary>
public async Task<LaunchSpec> BuildResumeAsync(string sessionId, string paneId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var session = await new MissionControlSessionRepository(ctx).GetAsync(sessionId, ct)
?? throw new KeyNotFoundException($"Session not found: {sessionId}");
if (!Directory.Exists(session.Cwd))
throw new InvalidOperationException($"Directory does not exist: {session.Cwd}");
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
IReadOnlyList<string> args =
[
.. await PaneSessionArgsAsync(paneId, sessionId, ct),
.. WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), EffortFor(settings, settings.DefaultModel)),
];
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(session.Cwd, resolvedClaude, args, env);
}
// Same title an ad-hoc tile shows: the folder name, or the whole path for a drive root.
private static string FolderTitle(string directory) =>
Path.GetFileName(directory.TrimEnd('\\', '/')) is { Length: > 0 } name ? name : directory;
/// <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>
@@ -130,6 +130,8 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(cwd, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync(
@@ -42,6 +42,23 @@ public class MissionControlViewModelTests : IDisposable
private MissionControlViewModel BuildVm(StubWorkerClient worker)
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
[Fact]
public void RecentSessions_NestHandoffs_PutsIndirectHandoffsUnderTheirOrigin()
{
var t0 = new DateTime(2026, 9, 28, 8, 0, 0, DateTimeKind.Utc);
MissionControlSessionEntity S(string id, int minutes, string? from = null)
=> new() { SessionId = id, Cwd = "c", Title = id, StartedAt = t0.AddMinutes(minutes), PredecessorSessionId = from };
// Newest first, as ListRecentAsync returns them. "orphan" names a predecessor outside the window.
var newestFirst = new[] { S("c", 40, "b"), S("other", 30), S("b", 20, "a"), S("orphan", 15, "gone"), S("a", 10) };
var rows = MissionControlViewModel.NestHandoffs(newestFirst)
.Select(r => (r.Session.SessionId, r.IsHandoff)).ToList();
Assert.Equal(
new[] { ("a", false), ("b", true), ("c", true), ("other", false), ("orphan", false) },
rows);
}
// Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two
// overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix
// to completion before either resumes past the DB await. This reproduces the open-path races
@@ -712,6 +712,43 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(ArgAfter(spec, "--session-id"), await ReadInteractiveSessionIdAsync(taskId));
}
[Fact]
public async Task RecentSession_AdHocThenHandoff_RecordsChainAndResumesInCwd()
{
var paneId = NewPaneId();
var svc = BuildService();
var origin = ArgAfter(await svc.BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None), "--session-id");
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1");
Directory.CreateDirectory(dir);
var briefPath = Path.Combine(dir, "brief.md");
File.WriteAllText(briefPath, $"Handed off from session `{origin}`.\n\nbrief");
var handoff = ArgAfter(await svc.BuildHandoffAsync(paneId, _tempDir, briefPath, null, CancellationToken.None), "--session-id");
using (var ctx = _db.CreateContext())
{
var repo = new MissionControlSessionRepository(ctx);
var first = await repo.GetAsync(origin);
var second = await repo.GetAsync(handoff);
Assert.NotNull(first);
Assert.Null(first!.PredecessorSessionId);
Assert.Equal(origin, second!.PredecessorSessionId);
Assert.Equal(first.Title, second.Title);
Assert.Equal(briefPath, second.BriefPath);
}
var resumePane = NewPaneId();
var resume = await svc.BuildResumeAsync(handoff, resumePane, CancellationToken.None);
Assert.Equal(_tempDir, resume.Cwd);
Assert.Equal(handoff, ArgAfter(resume, "--resume"));
AssertHandoffHint(resume, resumePane, handoff);
}
[Fact]
public async Task BuildResumeAsync_UnknownSession_ThrowsKeyNotFound()
=> await Assert.ThrowsAsync<KeyNotFoundException>(
() => BuildService().BuildResumeAsync(Guid.NewGuid().ToString(), NewPaneId(), CancellationToken.None));
[Fact]
public async Task BuildHandoffAsync_BriefOutsidePaneDir_Throws()
{