Skip to content

Commit f72be2b

Browse files
Desktop shell: Home with repository and harness selection (#653)
* docs(plan): AI-2194 desktop shell Home implementation plan * feat(ipc): advertise supported vendors on the daemon status snapshot * feat(app): remember the chosen harness per repository Add HarnessByRepo member to AppState to persist the vendor harness choice per repository path. Null key means the choice was never made; empty string key ("") holds the choice for the scratch "No repository" target. Tests verify serialization round-trip and null default behavior. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(app): harness catalogue driven by the daemon's advertised vendors Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(app): rename HarnessCatalog to HostedHarnessCatalog, derive vendors from Core Removes duplicated source of truth by deriving the vendor list from Capacitor.Cli.Core.Setup.HarnessCatalog.All instead of maintaining a separate Known array. Transport family (pty/acp/rpc) is now kept in a private map as it's specific to the daemon's hosting strategy, separate from Core's vendor registration which handles installation flags and detection logic. This fixes the name collision that prevented using the new HostedHarnessCatalog class alongside Core's HarnessCatalog without explicit qualification. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(app): remove unnecessary qualification of HarnessCatalog reference Now that the app's harness catalogue is renamed to HostedHarnessCatalog, the unqualified HarnessCatalog reference correctly resolves to Core's HarnessCatalog via the using statement. The workaround qualification is no longer needed. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * feat(app): launch sessions through the server hub Home needs every vendor, but the daemon's local Spawn frame only resolves claude/codex against its PTY launcher dictionary. The server's RequestLaunchAgentV2 reaches all nine vendors through the runtime factories, so the launch path goes through the hub instead of the local socket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(app): send RequestLaunchAgentV2 payload keys in snake_case The server applies PropertyNamingPolicy = SnakeCaseLower to every hub payload (kcap-server JsonDefaults.ConfigureSignalRPayload); the client's payload was serializing camelCase keys, so DaemonName and RepoPath would bind null server-side and every launch would fail. Fix both the wire naming (explicit snake_case [JsonPropertyName] on every member, plus the same SnakeCaseLower policy the daemon's own ServerConnection/WatchCommand apply) and the test gap that missed it: LaunchRequestTests now serializes through LaunchHubJson.Configure, the exact JsonSerializerOptions ServerLaunchClient hands AddJsonProtocol, instead of a bare context that only proved the client's own idea of the format. Adds a test pinning the full twelve-key set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(app): Home view-model with per-repository harness memory Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(app): marshal HomeViewModel's daemon projections onto the UI thread Sessions/Harnesses were bound straight off IDaemonClientService's background thread, matching neither MainWindowViewModel nor ConsentPromptViewModel's ObserveOn-before-binding rule. Add RxSchedulers.MainThreadScheduler ObserveOn before SortAndBind/ToProperty, and bring HomeViewModelTests into the AvaloniaSession.WithImmediateRxScheduler / NotInParallel("AvaloniaSession") cohort those schedulers require. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(app): Home surface with repository and harness selection Adds Home as the first MainWindow tab, backed by a new HomeView bound to Task 5's HomeViewModel: goal input, repository/harness chips, remember toggle, Start, and the active-sessions grid. Introduces the design's dark palette as Application-level resources (App.axaml) instead of per-control hex literals. HomeViewModel now implements IDisposable via a CompositeDisposable, matching TrayViewModel/ActivityViewModel, since this task is what first constructs one. * fix(app): wire HomeViewModel through the composition root, make Home the default tab Task 6 fix round: HomeView was inert (no DataContext) and Agents was pinned as the default tab to dodge two smoke-test assumptions instead of fixing them. - App.BuildAndShowMainWindow now constructs HomeViewModel over the same IDaemonClientService instance MainWindowViewModel uses (never a second daemon connection), plus a fresh AppStateStore/ServerLaunchClient — the same cheap-construction pattern BuildLifecycleController already relies on. MainWindowViewModel exposes it as Home; MainWindow.axaml binds HomeView's DataContext to it. App reads Home back off the built window's own DataContext into a new _home field, so BuildAndShowMainWindow's signature (and therefore AppStartupTests' direct call to it) never changes; _home disposes through the same UI-disposables list as _activity/_trayVm/_pause, on both the normal shutdown and startup-failure paths. - Removed the IsSelected="True" pin on Agents so Home is genuinely the default tab, and updated the two MainWindowSmokeTests that assumed Agents opened first to select it explicitly before asserting on its content. * fix(app): correct false claims and thread/lifetime hazards on the Home surface The scratch target's comments claimed a "" repo path launches into a daemon-owned worktree; AgentOrchestrator rejects any repo path that fails Directory.Exists, so the key is storage-only until the daemon accepts a repo-less launch. The concept and its key handling stay. ServerLaunchClient leaked a HubConnection whenever StartAsync threw (the instance was never assigned to _hub) and disposed its gate out from under an in-flight launch. The client is now held by the composition root, shared across window rebuilds, and disposed after Home on both teardown paths. SessionCardViewModel built SolidColorBrushes on the daemon pump thread, which worked only by accident of per-instance dispatcher affinity; ImmutableSolidColorBrush is not an AvaloniaObject, so the four dots are shared rather than reallocated per card per revision. Also fixes the tab comments Home's arrival falsified, and the JSON naming comment in ILaunchClient: an explicit [JsonPropertyName] always beats a policy, and a policy on JsonSerializerOptions does reach source-generated metadata. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(app): cover the daemon-advertised picker, the UI-thread marshalling and the vendor map Three gaps the branch left unfalsifiable. A snapshot narrowing the harness picker was untested end to end (the fake's supportedVendors parameter had no caller). HomeViewModel's ObserveOn before SortAndBind could be deleted with every test still green: the suite pins the scheduler to Immediate and pushes from the UI thread. The new smoke test pushes from a background thread over the session's real scheduler and asserts the bound collection is mutated ON the UI thread — "does not throw" is not falsifiable here, since the push raises nothing and the container still realizes even unmarshalled (a bare VerifyAccess and a control property set from the same thread do throw, so the harness enforces affinity; this path defers its UI work). The transport-family map is hand-written while the vendor list comes from Core, so a tenth vendor would be labelled "chat" silently. The runtime fallback stays — an unknown advertised vendor must still be listed — but the gap is now a red suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(plan): correct the AOT constraint, hub payload shape, and Task 5 type name Three corrections made while executing, so the plan matches what was built: - Capacitor.Cli is the only AOT-published project; the publish gate never compiles Capacitor.App, so it cannot be evidence about app code. - The hub payload is a source-generated record, not an anonymous type. - Task 5 consumes HostedHarnessCatalog; HarnessCatalog is Core's own type. * fix(app): address PR review findings - Thread the shutdown token into HomeViewModel.StartAsync; a launch held CancellationToken.None and could not be cancelled against teardown. - Hold the connection gate across the hub invoke. GetConnectionAsync disposes and rebuilds a connection that is not Connected, so releasing before the invoke let a second launch dispose one still in use. - Compare repo keys the way the filesystem does: case-insensitive on Windows and macOS, case-sensitive on Linux. Applied on read, since System.Text.Json rebuilds the dictionary with an ordinal comparer. - Assert JSON null through JsonElementExtensions.IsNull rather than reading ValueKind directly. - Remove Linear issue IDs from source comments (CI gate). --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 70c0257 commit f72be2b

27 files changed

Lines changed: 2238 additions & 15 deletions

docs/superpowers/plans/2026-08-23-ai2194-desktop-shell-home.md

Lines changed: 834 additions & 0 deletions
Large diffs are not rendered by default.

src/Capacitor.App/App.axaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
33
x:Class="Capacitor.App.App"
44
RequestedThemeVariant="Default">
5+
<Application.Resources>
6+
<!-- The Home surface's design palette. Kcap-prefixed keys so these never
7+
shadow a FluentTheme resource of the same short name — MainWindow's existing controls
8+
stay on FluentTheme defaults untouched. -->
9+
<ResourceDictionary>
10+
<SolidColorBrush x:Key="KcapCanvasBrush" Color="#0B0D12" />
11+
<SolidColorBrush x:Key="KcapSurfaceBrush" Color="#12151D" />
12+
<SolidColorBrush x:Key="KcapSurfaceRaisedBrush" Color="#191D27" />
13+
<SolidColorBrush x:Key="KcapBorderBrush" Color="#2A3040" />
14+
<SolidColorBrush x:Key="KcapTextBrush" Color="#F1F3F7" />
15+
<SolidColorBrush x:Key="KcapMutedBrush" Color="#9299AA" />
16+
<SolidColorBrush x:Key="KcapAccentBrush" Color="#5BE0B3" />
17+
<SolidColorBrush x:Key="KcapAccentDimBrush" Color="#173F36" />
18+
<SolidColorBrush x:Key="KcapWarningBrush" Color="#F4B860" />
19+
<SolidColorBrush x:Key="KcapWarningDimBrush" Color="#45351E" />
20+
<SolidColorBrush x:Key="KcapPurpleBrush" Color="#A994FF" />
21+
<SolidColorBrush x:Key="KcapDangerBrush" Color="#FF7272" />
22+
</ResourceDictionary>
23+
</Application.Resources>
524
<Application.Styles>
625
<FluentTheme />
726
</Application.Styles>

src/Capacitor.App/App.axaml.cs

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ public partial class App : Application {
6565
// frame: the prompt window factory and BuildAndShowMainWindow both close over the SAME
6666
// instance.
6767
ActivityViewModel? _activity;
68+
// Constructed INSIDE BuildAndShowMainWindow, over the same `service`
69+
// instance MainWindowViewModel itself uses — retrieved back off the built window's own
70+
// DataContext right below, so this field (and therefore disposal) never needs a second
71+
// construction path or a signature change to BuildAndShowMainWindow (AppStartupTests calls
72+
// that method directly, with no Home argument).
73+
HomeViewModel? _home;
74+
// Home's launch transport, held here because it is the one graph object that outlives a
75+
// window rebuild (MainWindowCoordinator can build a second window over the same client) and
76+
// owns a live HubConnection. Disposed after _home on both teardown paths — never before, or a
77+
// launch still in flight would lose its transport mid-invoke.
78+
ServerLaunchClient? _launch;
6879
TrayViewModel? _trayVm;
6980
TrayIconManager? _tray;
7081
// No disposal needed — RefCount tears its Interval down with its last subscriber, and every
@@ -158,7 +169,8 @@ async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop) {
158169
if (_wizardWindow is { IsVisible: true } wizard) wizard.Close();
159170
Console.Error.WriteLine($"kcap app failed to start: {ex}");
160171
await HandleStartupFailureAsync(
161-
desktop, ex, _service, _shutdown, [_tray, _trayVm, _promptCoordinator, _consent, _activity, _pause], _lifecycle, _lane);
172+
desktop, ex, _service, _shutdown, [_tray, _trayVm, _promptCoordinator, _consent, _activity, _home, _pause], _lifecycle, _lane);
173+
await DisposeLaunchClientAsync(); // after _home above — its only caller
162174
// all already disposed above — never let a later OnShutdownRequested (e.g. Cmd+Q
163175
// while the error window is up) dispose any of them a second time
164176
_service = null;
@@ -172,6 +184,7 @@ await HandleStartupFailureAsync(
172184
_consent = null;
173185
_pause = null;
174186
_activity = null;
187+
_home = null;
175188
_wizardAuth = null; // its attempt, if any, already settled through the wizard's own close path
176189
_wizardImport = null; // same — any in-flight run already settled through the wizard's own close path
177190
_wizardWindow = null;
@@ -263,15 +276,24 @@ void BuildDaemonGraph(
263276
Notifier = notifier,
264277
});
265278

279+
// One launch client for the app, not one per window the coordinator builds — each carries
280+
// its own HubConnection, and only a held instance can be disposed at teardown.
281+
var launch = new ServerLaunchClient();
282+
_launch = launch;
283+
266284
_coordinator = new MainWindowCoordinator(
267-
() => BuildAndShowMainWindow(service, actions, notifier, ticker, _shutdown.Token, activity, lifecycle.StartActionAsync, lifecycleStatus));
285+
() => BuildAndShowMainWindow(service, actions, notifier, ticker, _shutdown.Token, activity, lifecycle.StartActionAsync, lifecycleStatus, launch));
268286
// A shutdown that started before this continuation resumed already ran its first
269287
// pass against a null coordinator, so a window built now must never be
270288
// close-protected (BeginShutdownPass's rule 1 is the general defense; this is the
271289
// by-construction one, and it is why the window below cannot even briefly intercept).
272290
_coordinator.QuitInProgress = _shutdownStarted;
273291
_coordinator.ShowMainWindow();
274292
desktop.MainWindow = _coordinator.Window;
293+
// BuildAndShowMainWindow constructs Home itself (over the same `service`) — read back off
294+
// the window's own DataContext rather than threading a new parameter through, so
295+
// AppStartupTests' existing direct call to that method needs no change.
296+
_home = (_coordinator.Window?.DataContext as MainWindowViewModel)?.Home;
275297

276298
// LAST, deliberately (spec §9): anything above throwing lands in the catch with no
277299
// tray icon ever created, leaving the error window as the only surface.
@@ -563,12 +585,23 @@ internal static Window BuildStartupErrorWindow(Exception ex) =>
563585
internal static MainWindow BuildAndShowMainWindow(
564586
IDaemonClientService service, AgentActionService actions, IAppNotifier notifier, ITicker ticker,
565587
CancellationToken shutdownToken, ActivityViewModel activity, Func<CancellationToken, Task>? startAction = null,
566-
IObservable<string?>? lifecycleStatus = null) {
588+
IObservable<string?>? lifecycleStatus = null, ILaunchClient? launch = null) {
567589
// Notifier is set on the WINDOW (spec §11 toast overlay), not the ViewModel — the toast
568590
// is a View-level concern (WindowNotificationManager lives on MainWindow) independent of
569591
// the VM's WhenActivated-scoped projections.
592+
//
593+
// Home is built here, over the SAME `service` instance MainWindowViewModel
594+
// itself uses — never a second daemon connection. AppStateStore/ServerLaunchClient are both
595+
// cheap, self-contained constructions (file-path-gated I/O; a HubConnection that only opens
596+
// lazily on first StartAsync), the same reasoning BuildLifecycleController's own
597+
// `new AppStateStore(PathHelpers.ConfigPath("app-state.json"))` already relies on. The
598+
// composition root passes its held client so teardown can dispose it; a caller that passes
599+
// none (a test) gets an unheld one, which owns nothing until a launch is actually made.
600+
var home = new HomeViewModel(
601+
service, new AppStateStore(PathHelpers.ConfigPath("app-state.json")),
602+
launch ?? new ServerLaunchClient(), shutdownToken);
570603
var window = new MainWindow {
571-
DataContext = new MainWindowViewModel(service, actions, ticker, shutdownToken, activity, startAction, lifecycleStatus),
604+
DataContext = new MainWindowViewModel(service, actions, ticker, shutdownToken, activity, startAction, lifecycleStatus, home: home),
572605
Notifier = notifier,
573606
};
574607
window.Show();
@@ -993,16 +1026,20 @@ async Task DisposeAndShutdownAsync() {
9931026
// disposed one. A resolve already in flight was cancelled by _shutdown at the top of
9941027
// OnShutdownRequested and settles on the ViewModel's silent-abort path.
9951028
await DisposeUiThenConfirmShutdownAsync(
996-
[_tray, _trayVm, _promptCoordinator, _consent, _activity, _pause],
1029+
[_tray, _trayVm, _promptCoordinator, _consent, _activity, _home, _pause],
9971030
DisposeLifecycleAndServiceAsync, () => _shutdownConfirmed = true, desktop, _exitCode);
9981031
} else {
9991032
await DisposeLifecycleAndServiceAsync();
10001033
_shutdownConfirmed = true;
10011034
}
10021035
}
10031036

1004-
// _lifecycle goes first (guarded, so a throw never skips _service's disposal); the lane goes LAST — its substrate must outlive any caller still awaiting RunAsync.
1037+
// Runs after the UI disposables (DisposeUiThenConfirmShutdownAsync), so _home is already gone
1038+
// when its launch client is torn down here. _lifecycle then goes first (guarded, so a throw
1039+
// never skips _service's disposal); the lane goes LAST — its substrate must outlive any caller
1040+
// still awaiting RunAsync.
10051041
async ValueTask DisposeLifecycleAndServiceAsync() {
1042+
await DisposeLaunchClientAsync().ConfigureAwait(false);
10061043
if (_lifecycle is not null) {
10071044
try {
10081045
await _lifecycle.DisposeAsync().ConfigureAwait(false);
@@ -1020,6 +1057,19 @@ async ValueTask DisposeLifecycleAndServiceAsync() {
10201057
}
10211058
}
10221059

1060+
// Idempotent (both teardown paths can reach it) and guarded for the same reason DisposeAll is:
1061+
// a failing hub disposal must never skip the disposals that follow.
1062+
async ValueTask DisposeLaunchClientAsync() {
1063+
if (_launch is null) return;
1064+
1065+
try {
1066+
await _launch.DisposeAsync().ConfigureAwait(false);
1067+
} catch (Exception ex) {
1068+
Console.Error.WriteLine($"kcap app failed to dispose the launch client during teardown: {ex}");
1069+
}
1070+
_launch = null;
1071+
}
1072+
10231073
/// <summary>Quiesces shutdown in two phases: sign-in and import finish uncapped so an in-progress commit isn't torn down, then lifecycle/lane quiesce under the cap.</summary>
10241074
internal static async Task QuiesceAppAsync(
10251075
WizardAuthService? auth, ImportStepViewModel? import,

src/Capacitor.App/Capacitor.App.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<PackageReference Include="Avalonia.Themes.Fluent" />
1919
<PackageReference Include="ReactiveUI.Avalonia" />
2020
<PackageReference Include="DynamicData" />
21+
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
2122
</ItemGroup>
2223
<ItemGroup>
2324
<!-- .axaml files are auto-included as AvaloniaXaml by Avalonia's build props; plain

src/Capacitor.App/Services/AppStateStore.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ public sealed record AppState(
1010
bool ShimOffered = false,
1111
bool ShimDenied = false,
1212
IReadOnlyList<string>? DeclinedTakeoverPairs = null,
13-
bool ConsentQuarantineAcked = false);
13+
bool ConsentQuarantineAcked = false,
14+
// Absolute repo path -> vendor token. "" is the reserved key for the not-yet-in-a-repository
15+
// target (HomeViewModel.ScratchRepoPath) — a stored preference only, since the daemon does
16+
// not accept a repo-less launch. Absent key = never chosen here; the caller picks its own
17+
// default rather than inheriting another repository's choice.
18+
IReadOnlyDictionary<string, string>? HarnessByRepo = null);
1419

1520
public interface IAppStateStore {
1621
Task<AppState> LoadAsync();
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using Capacitor.Cli.Core.Setup;
2+
3+
namespace Capacitor.App.Services;
4+
5+
public sealed record HarnessOption(string Vendor, string Label, string TransportFamily, bool Available);
6+
7+
/// The picker's vendor list. Availability comes from what the daemon advertises
8+
/// (DaemonInfoDto.SupportedVendors), never from a version check — a vendor auto-update must not
9+
/// silently withdraw a harness. A vendor the daemon advertises but this build has never heard of
10+
/// is still offered, listed under its raw token: the daemon is the authority on what it can host.
11+
public static class HostedHarnessCatalog {
12+
// Transport family for each vendor: how the daemon hosts it (pty, acp, or rpc).
13+
// Vendors absent from this map default to "rpc" — which reads as "chat" in the picker, true or
14+
// not, so HostedHarnessCatalogTests pins every vendor in Core's HarnessCatalog to an entry
15+
// here. The fallback stays for a vendor only the DAEMON knows about; it is not a licence to
16+
// skip a tenth entry when one is added to Core.
17+
static readonly Dictionary<string, string> TransportFamilies = new(StringComparer.OrdinalIgnoreCase) {
18+
{ "claude", "pty" },
19+
{ "codex", "pty" },
20+
{ "cursor", "acp" },
21+
{ "copilot", "acp" },
22+
{ "gemini", "acp" },
23+
{ "kiro", "acp" },
24+
{ "opencode", "acp" },
25+
{ "antigravity", "rpc" },
26+
{ "pi", "rpc" },
27+
};
28+
29+
/// The vendors with an EXPLICIT family above — what the guard test reads, since Build's
30+
/// fallback makes an unmapped vendor indistinguishable from a mapped "rpc" one.
31+
internal static IReadOnlyCollection<string> MappedVendors => TransportFamilies.Keys;
32+
33+
public static IReadOnlyList<HarnessOption> Build(string[]? supportedVendors) {
34+
// null = an older daemon that never sent the field: unknown, not empty.
35+
var advertised = supportedVendors is null
36+
? null
37+
: new HashSet<string>(supportedVendors, StringComparer.OrdinalIgnoreCase);
38+
39+
var options = HarnessCatalog.All
40+
.Select(k => new HarnessOption(
41+
k.VendorId,
42+
k.Label,
43+
TransportFamilies.TryGetValue(k.VendorId, out var family) ? family : "rpc",
44+
advertised?.Contains(k.VendorId) ?? true))
45+
.ToList();
46+
47+
if (advertised is null) return options;
48+
49+
var known = new HashSet<string>(HarnessCatalog.All.Select(k => k.VendorId), StringComparer.OrdinalIgnoreCase);
50+
foreach (var extra in supportedVendors!.Where(v => !known.Contains(v)).Distinct(StringComparer.OrdinalIgnoreCase))
51+
options.Add(new HarnessOption(extra, extra, "rpc", true));
52+
53+
return options;
54+
}
55+
56+
public static string DescriptionFor(HarnessOption option) => option.TransportFamily switch {
57+
"pty" => "PTY · terminal + chat",
58+
"acp" => "ACP · chat",
59+
_ => "chat",
60+
};
61+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Capacitor.App.Services;
4+
5+
public sealed record LaunchRequest(string DaemonName, string RepoPath, string Vendor, string? Prompt);
6+
7+
public sealed record LaunchOutcome(bool Started, string? AgentId, string? Error);
8+
9+
/// Starting a session goes through the SERVER, not the local socket: the local Spawn frame
10+
/// resolves against the daemon's PTY launchers (claude and codex only), while the server's
11+
/// RequestLaunchAgentV2 reaches every vendor through the runtime factories.
12+
public interface ILaunchClient {
13+
Task<LaunchOutcome> StartAsync(LaunchRequest request, CancellationToken ct);
14+
}
15+
16+
/// The RequestLaunchAgentV2 hub argument. A concrete record, not an anonymous type: the app
17+
/// serializes through source-generated contexts throughout (AppStateStore, KcapCli), and a
18+
/// reflection dependency here would foreclose ever AOT-publishing it. Member names must match
19+
/// LaunchAgentRequestV2's properties — the hub binds by name.
20+
// Explicit snake_case on every member: the server applies PropertyNamingPolicy =
21+
// SnakeCaseLower to all hub payloads (kcap-server JsonDefaults.ConfigureSignalRPayload), and
22+
// LaunchHubJson.Configure sets the same policy here. The explicit names are what survive that
23+
// policy whatever it is set to, and they put the wire contract in plain sight next to the
24+
// server record each member must match — this file has already shipped one launch-breaking
25+
// key-casing defect, so the names are pinned rather than derived.
26+
public sealed record LaunchAgentRequestV2Payload {
27+
[JsonPropertyName("daemon_name")] public required string DaemonName { get; init; }
28+
[JsonPropertyName("prompt")] public string? Prompt { get; init; }
29+
[JsonPropertyName("model")] public required string Model { get; init; }
30+
[JsonPropertyName("effort")] public string? Effort { get; init; }
31+
[JsonPropertyName("repo_path")] public required string RepoPath { get; init; }
32+
[JsonPropertyName("tools")] public string[]? Tools { get; init; }
33+
[JsonPropertyName("attachment_ids")] public string[]? AttachmentIds { get; init; }
34+
[JsonPropertyName("visibility")] public string? Visibility { get; init; }
35+
[JsonPropertyName("grants")] public object[]? Grants { get; init; }
36+
[JsonPropertyName("vendor")] public required string Vendor { get; init; }
37+
[JsonPropertyName("codex_posture")] public object? CodexPosture { get; init; }
38+
[JsonPropertyName("acp_permission_preset")] public string? AcpPermissionPreset { get; init; }
39+
}
40+
41+
[JsonSerializable(typeof(LaunchAgentRequestV2Payload))]
42+
public partial class LaunchJsonContext : JsonSerializerContext;
43+
44+
/// The RequestLaunchAgentV2 argument, split from the transport so its shape is testable.
45+
public static class LaunchPayload {
46+
public static LaunchAgentRequestV2Payload For(LaunchRequest r) => new() {
47+
DaemonName = r.DaemonName,
48+
Prompt = string.IsNullOrWhiteSpace(r.Prompt) ? null : r.Prompt,
49+
Model = "", // vendor default; the server rejects null
50+
RepoPath = r.RepoPath,
51+
Vendor = r.Vendor,
52+
};
53+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System.Text.Json;
2+
3+
namespace Capacitor.App.Services;
4+
5+
/// <summary>
6+
/// The EXACT SignalR JSON hub-protocol payload configuration ServerLaunchClient applies —
7+
/// extracted so wire-contract tests serialize with the genuine on-wire options instead of a
8+
/// hand-built approximation that could silently diverge from production (mirrors kcap-server's
9+
/// JsonDefaults.ConfigureSignalRPayload, extracted for the same reason). Matches
10+
/// ServerConnection.cs / WatchCommand.cs: chain-insert the generated context rather than
11+
/// replacing TypeInfoResolver, and set the same snake_case policy the server expects on every
12+
/// hub payload — belt and braces alongside the payload's own explicit [JsonPropertyName]s.
13+
/// </summary>
14+
public static class LaunchHubJson {
15+
public static void Configure(JsonSerializerOptions options) {
16+
options.TypeInfoResolverChain.Insert(0, LaunchJsonContext.Default);
17+
options.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
18+
}
19+
}

0 commit comments

Comments
 (0)