diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs
index 0cef71f3..3f8fe2f0 100644
--- a/src/ClaudeDo.Data/Wire.cs
+++ b/src/ClaudeDo.Data/Wire.cs
@@ -105,6 +105,11 @@ public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPa
public record SeedResultDto(int Copied, int Skipped);
+/// What worker.config.json holds (a command name or a full path).
+/// Where it actually resolves to right now, or null if nothing resolves —
+/// the whole point of surfacing this setting is telling those two cases apart.
+public record ClaudeBinDto(string Value, string? ResolvedPath);
+
public record OnlineInboxStateDto(
bool Enabled,
string ApiBaseUrl,
diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
index bfbb19c8..8e67a927 100644
--- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
@@ -194,6 +194,9 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState);
Task ForceRemoveWorktreeAsync(string taskId);
+ Task GetClaudeBinAsync();
+ Task SetClaudeBinAsync(string? value);
+
Task GetOnlineInboxStateAsync();
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
Task SetOnlineInboxAuthAsync(string refreshToken);
diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs
index d83b5413..27077d3b 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -664,6 +664,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await InvokeTimedAsync("QueuePlanningSubtasksAsync", () => _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct));
}
+ public Task GetClaudeBinAsync()
+ => TryInvokeAsync("GetClaudeBin");
+
+ public Task SetClaudeBinAsync(string? value)
+ => TryInvokeAsync("SetClaudeBin", value);
+
public Task GetOnlineInboxStateAsync()
=> TryInvokeAsync("GetOnlineInboxState");
diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs
index 4013bd66..fcd050fe 100644
--- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs
@@ -12,6 +12,12 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
private readonly IWorkerClient _worker;
public OperationStatus RestoreOp { get; } = new();
+ public OperationStatus ClaudeBinOp { get; } = new();
+
+ // worker.config.json's claude_bin: a command name or a full path to the CLI. Saved on its own
+ // (not with the modal's Save button) because the worker owns the file, not app_settings.
+ [ObservableProperty] private string _claudeBin = "";
+ [ObservableProperty] private string _claudeBinResolvedHint = "";
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _hasCustomizedPrompts;
@@ -37,6 +43,34 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
};
}
+ public async Task LoadClaudeBinAsync()
+ {
+ var dto = await _worker.GetClaudeBinAsync();
+ if (dto is null) return;
+ ClaudeBin = dto.Value;
+ ApplyResolvedHint(dto);
+ }
+
+ [RelayCommand]
+ private async Task SaveClaudeBin()
+ {
+ StatusMessage = "";
+ using var op = ClaudeBinOp.Begin(Loc.T("ops.claudeBin.saving"));
+ var dto = await _worker.SetClaudeBinAsync(ClaudeBin);
+ if (dto is null)
+ {
+ StatusMessage = Loc.T("vm.filesTab.workerOffline");
+ return;
+ }
+ ClaudeBin = dto.Value;
+ ApplyResolvedHint(dto);
+ }
+
+ private void ApplyResolvedHint(ClaudeBinDto dto) =>
+ ClaudeBinResolvedHint = dto.ResolvedPath is null
+ ? Loc.T("settings.files.claudeBinUnresolved")
+ : Loc.T("settings.files.claudeBinResolved", dto.ResolvedPath);
+
private bool CanRestoreDefaultAgents() => !RestoreOp.IsRunning;
[RelayCommand(CanExecute = nameof(CanRestoreDefaultAgents))]
diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
index 3045bdd8..5a8a2026 100644
--- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs
+++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
@@ -96,6 +96,14 @@ public sealed class WorkerConfig
/// Reads the existing JSON, replaces the online_inbox node, and writes back indented.
///
public void SaveOnlineInbox(string? path = null)
+ => SaveKey("online_inbox", JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts), path);
+
+ /// Persists ONLY claude_bin, same read-modify-write as
+ /// — every other field in the file stays untouched.
+ public void SaveClaudeBin(string? path = null)
+ => SaveKey("claude_bin", JsonValue.Create(ClaudeBin), path);
+
+ private static void SaveKey(string key, JsonNode? value, string? path)
{
path ??= DefaultConfigPath;
@@ -103,7 +111,7 @@ public sealed class WorkerConfig
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
: new JsonObject();
- root["online_inbox"] = JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts);
+ root[key] = value;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, root.ToJsonString(WriteOpts));
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index 1dd28135..69818515 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Reflection;
using ClaudeDo.Data;
+using ClaudeDo.Data.Environment;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Agents;
@@ -1000,6 +1001,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return ids.Count;
}
+ public ClaudeBinDto GetClaudeBin() => DescribeClaudeBin();
+
+ /// Blank resets to the plain "claude" command. WorkerConfig is a DI singleton and
+ /// ClaudeProcess resolves it per spawn, so the change applies to the next run without a
+ /// restart — but it is persisted too, since Load() re-reads the file on startup.
+ public ClaudeBinDto SetClaudeBin(string? value)
+ {
+ _cfg.ClaudeBin = string.IsNullOrWhiteSpace(value) ? "claude" : value.Trim();
+ _cfg.SaveClaudeBin();
+ _ = _broadcaster.WorkerLog($"claude_bin set to '{_cfg.ClaudeBin}'", WorkerLogLevel.Info, DateTime.UtcNow);
+ return DescribeClaudeBin();
+ }
+
+ private ClaudeBinDto DescribeClaudeBin() =>
+ new(_cfg.ClaudeBin, ExecutableResolver.Resolve(_cfg.ClaudeBin)?.Path);
+
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here.
public OnlineInboxStateDto GetOnlineInboxState()
{
diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
index 69ff19a1..df0e4ee5 100644
--- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
+++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
@@ -172,6 +172,8 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task GetWorkerBuildInfoAsync() => Task.FromResult(WorkerBuildInfo);
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
+ public virtual Task GetClaudeBinAsync() => Task.FromResult(null);
+ public virtual Task SetClaudeBinAsync(string? value) => Task.FromResult(null);
public virtual Task GetOnlineInboxStateAsync() => Task.FromResult(null);
public virtual Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
diff --git a/tests/ClaudeDo.Worker.Tests/Config/WorkerConfigSaveTests.cs b/tests/ClaudeDo.Worker.Tests/Config/WorkerConfigSaveTests.cs
new file mode 100644
index 00000000..4431ec24
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Config/WorkerConfigSaveTests.cs
@@ -0,0 +1,46 @@
+using ClaudeDo.Worker.Config;
+using Xunit;
+
+namespace ClaudeDo.Worker.Tests.Config;
+
+/// SaveClaudeBin/SaveOnlineInbox are read-modify-write on a file the user also hand-edits, so the
+/// one thing that must hold is: every other key survives.
+public sealed class WorkerConfigSaveTests : IDisposable
+{
+ private readonly string _path = Path.Combine(
+ Path.GetTempPath(), $"cd_workercfg_{Guid.NewGuid():N}.json");
+
+ public void Dispose() { try { File.Delete(_path); } catch { } }
+
+ [Fact]
+ public void SaveClaudeBin_keeps_every_other_key()
+ {
+ File.WriteAllText(_path, """
+ {
+ "claude_bin": "claude",
+ "signalr_port": 12345,
+ "external_mcp_api_key": "secret",
+ "online_inbox": { "enabled": true }
+ }
+ """);
+
+ var cfg = WorkerConfig.Load(_path);
+ cfg.ClaudeBin = @"C:\tools\claude.cmd";
+ cfg.SaveClaudeBin(_path);
+
+ var reloaded = WorkerConfig.Load(_path);
+ Assert.Equal(@"C:\tools\claude.cmd", reloaded.ClaudeBin);
+ Assert.Equal(12345, reloaded.SignalRPort);
+ Assert.Equal("secret", reloaded.ExternalMcpApiKey);
+ Assert.True(reloaded.OnlineInbox.Enabled);
+ }
+
+ [Fact]
+ public void SaveClaudeBin_creates_the_file_when_missing()
+ {
+ var cfg = new WorkerConfig { ClaudeBin = "claude-next" };
+ cfg.SaveClaudeBin(_path);
+
+ Assert.Equal("claude-next", WorkerConfig.Load(_path).ClaudeBin);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
index 6bd31701..27df187d 100644
--- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
@@ -159,6 +159,8 @@ sealed class FakeWorkerClient : IWorkerClient
public Task GetLastPrepLogAsync() => Task.FromResult(string.Empty);
public Task GetWorkerBuildInfoAsync() => Task.FromResult(null);
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
+ public Task GetClaudeBinAsync() => Task.FromResult(null);
+ public Task SetClaudeBinAsync(string? value) => Task.FromResult(null);
public Task GetOnlineInboxStateAsync() => Task.FromResult(null);
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;