From c2166a0d4fee8fdd1703d84b97234ef490d44df3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 2 Sep 2026 21:05:55 +0200 Subject: [PATCH] feat(worker): start_list_handler MCP-Tool startet einen List-Handler-Run wie der Button --- .../Services/Interfaces/IWorkerClient.cs | 4 ++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 6 ++ .../ViewModels/MissionControlViewModel.cs | 5 ++ .../External/ListHandlerMcpTools.cs | 72 +++++++++++++++++++ src/ClaudeDo.Worker/Hub/HubBroadcaster.cs | 5 ++ src/ClaudeDo.Worker/Program.cs | 2 + tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 2 + 7 files changed, 96 insertions(+) create mode 100644 src/ClaudeDo.Worker/External/ListHandlerMcpTools.cs diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index e952a3c3..e081fd8a 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged /// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final"). event Action, string>? HandoffRequestedEvent; + /// 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. + event Action>? ListHandlerStartRequestedEvent; + /// An agent added a note through the MCP tool — an open notes editor reloads. event Action? NotesUpdatedEvent; diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index f97140e5..832cc581 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; public event Action, string>? HandoffRequestedEvent; + public event Action>? ListHandlerStartRequestedEvent; public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? ListUpdatedEvent; @@ -159,6 +160,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase)); }); + _hub.On>("ListHandlerStartRequested", (listId, taskIds) => + { + Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds)); + }); + _hub.On("WorktreeUpdated", taskId => { Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId)); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 45c210b1..d46183d5 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -21,6 +21,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; private readonly Action, string> _onHandoffRequested; + private readonly Action> _onListHandlerStartRequested; // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the // review/merge/status machinery. @@ -98,6 +99,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); }; _worker.HandoffRequestedEvent += _onHandoffRequested; + _onListHandlerStartRequested = (listId, taskIds) => { _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); }; + _worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested; + _ = RefreshQueueAsync(); _ = RefreshSessionTargetsAsync(); } @@ -519,6 +523,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.ConnectionRestoredEvent -= _onConnectionRestored; _worker.HandoffRequestedEvent -= _onHandoffRequested; + _worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested; ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; Panes.CollectionChanged -= OnPanesChanged; foreach (var c in ConPtySessions.ToList()) diff --git a/src/ClaudeDo.Worker/External/ListHandlerMcpTools.cs b/src/ClaudeDo.Worker/External/ListHandlerMcpTools.cs new file mode 100644 index 00000000..6c8d873f --- /dev/null +++ b/src/ClaudeDo.Worker/External/ListHandlerMcpTools.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.External; + +public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList TaskNumbers); + +// The "Let Claude handle it" button, reachable from outside the UI. The worker only broadcasts; +// the App owns the ConPTY tile and creates the handler task exactly as the button does +// (MissionControlViewModel.OpenMergeHelperConPtySessionAsync), so there is one code path for +// starting a run, not two. Without a connected App nothing happens -- the result says so. +[McpServerToolType] +public sealed class ListHandlerMcpTools +{ + private readonly ListRepository _lists; + private readonly TaskRepository _tasks; + private readonly HubBroadcaster _broadcaster; + + public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster) + { + _lists = lists; + _tasks = tasks; + _broadcaster = broadcaster; + } + + [McpServerTool, Description( + "Start a list-handler run (\"Let Claude handle it\") for a list, exactly like the button in " + + "Mission Control: the connected ClaudeDo App creates the handler task and opens the triage " + + "ConPTY tile for the given tasks. taskIds defaults to every Idle, non-manual task of the list. " + + "Requires the App to be running and connected to this worker -- the worker only broadcasts " + + "the request. taskIds accept task numbers (#123 or bare 123).")] + public async Task StartListHandler( + [Description("The list to run the handler on.")] string listId, + [Description("Tasks to hand to the run, in the order to triage them. Omit for all Idle, non-manual tasks of the list.")] IReadOnlyList? taskIds = null, + CancellationToken cancellationToken = default) + { + var list = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + var listTasks = await _tasks.GetByListIdAsync(listId, cancellationToken); + List chosen; + if (taskIds is { Count: > 0 }) + { + var resolved = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken); + chosen = new List(); + foreach (var id in resolved) + { + var t = listTasks.FirstOrDefault(x => x.Id == id) + ?? throw new InvalidOperationException($"Task {id} not found in list {list.Name}."); + chosen.Add(t); + } + } + else + { + chosen = listTasks + .Where(t => t.Status == TaskStatus.Idle && !t.IsManual && t.ParentTaskId is null) + .OrderBy(t => t.SortOrder) + .ToList(); + } + + if (chosen.Count == 0) + throw new InvalidOperationException($"No tasks to hand to the list handler for {list.Name}."); + + var ids = chosen.Select(t => t.Id).ToList(); + await _broadcaster.ListHandlerStartRequested(listId, ids); + return new StartListHandlerResult(true, listId, list.Name, ids.Count, chosen.Select(t => t.Number).ToList()); + } +} diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index 4c8430bd..9f699bbd 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -37,6 +37,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task HandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase) => _hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase); + // 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 taskIds) => + _hub.Clients.All.SendAsync("ListHandlerStartRequested", listId, taskIds); + /// An agent wrote a note through MCP — the open notes editor reloads. public Task NotesUpdated() => _hub.Clients.All.SendAsync("NotesUpdated"); diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 5ef1b631..36e8cccd 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -357,6 +357,7 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); @@ -377,6 +378,7 @@ if (cfg.ExternalMcpPort > 0) .WithTools() .WithTools() .WithTools() + .WithTools() .WithTools() .WithTools() .WithTools() diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 382ac582..679ba373 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; public event Action, string>? HandoffRequestedEvent; + public event Action>? ListHandlerStartRequestedEvent; public event Action? NotesUpdatedEvent; public event Action? PrepStartedEvent; public event Action? PrepLineEvent; @@ -56,6 +57,7 @@ public abstract class StubWorkerClient : IWorkerClient public void RaiseListUpdated(string listId) => ListUpdatedEvent?.Invoke(listId); public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question); public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId); + public void RaiseListHandlerStartRequested(string listId, IReadOnlyList taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds); public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase); public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList files, bool externallyDriven) => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);