using System.Diagnostics; using System.Text.Json; namespace WslcSpike; /// /// M1 spike (a2) — the wslc happy path, end to end, through the real broker /// (docs/WINDOWS_PORT.md §13). /// /// Everything in the Windows container subsystem now compiles and none of it has ever run: /// `WslcFacade` (the compat SDK arm), `WslcInternal` (D13 Tier 1 recovery), the §3.3 RPC surface. /// This drives all of it as hostd would — spawn `nucleic-brokerd.exe`, speak NDJSON over stdio — /// so what passes here is the shipping code, not a rehearsal of it. /// /// It answers three questions the plan is still guessing at: /// /// 1. **Does the happy path work at all?** session → GHCR pull → container with an NTFS /// `ContainerVolume` → exec → stdio round-trip → signal → teardown. /// 2. **Is 9P fast enough to put repos on NTFS (D8)?** §15 lists this as a top risk with no /// numbers behind it. The spike times `git status` in the bind-mounted worktree AND in a copy /// on the container's own ext4, which is the comparison that actually isolates the mount. /// 3. **Does D13 Tier 1 recovery work?** Kill the broker with the session up, start another, and /// see whether it clears the orphan instead of failing `session_exists`. /// /// Nothing here is asserted-and-exits: every step prints what happened, because a spike's product /// is evidence. It exits non-zero only if a step that should work threw. /// internal static class Program { private static async Task Main(string[] args) { var repo = Arg(args, "--repo") ?? Directory.GetCurrentDirectory(); var image = Arg(args, "--image") ?? "ghcr.io/abkslm/hydrangeaos-agent:26.07"; var container = Arg(args, "--container") ?? "nucleic-spike-c1"; var sessionName = Arg(args, "--session-name") ?? "nucleic-spike"; var iterations = int.TryParse(Arg(args, "--iterations"), out var n) ? n : 5; var broker = Arg(args, "--broker") ?? FindBroker(); var skipRecovery = args.Contains("--no-recovery"); // hydrangeaOS runs `hydrangeaos-init` as PID 1 and stays up (docs/HYDRANGEAOS.md). A stock image usually // does not: alpine's PID 1 is /bin/sh, which exits immediately with no tty, so the // container is `Exited` before the first exec and every later step fails `not_running`. var sleepInit = args.Contains("--sleep-init"); var verbose = args.Contains("--verbose"); if (broker is null || !File.Exists(broker)) { Console.Error.WriteLine( "could not find nucleic-brokerd.exe. Build it first:\n" + " dotnet build windows\\NucleicBroker\\NucleicBroker.csproj -p:UseWslc=true\n" + "then pass --broker if it still isn't found."); return 2; } Console.WriteLine($"broker : {broker}"); Console.WriteLine($"repo : {repo}"); Console.WriteLine($"image : {image}"); Console.WriteLine(); try { await RunScenarioAsync(broker, sessionName, image, container, repo, iterations, sleepInit, verbose); if (!skipRecovery) await RunRecoveryAsync(broker, sessionName); return 0; } catch (Exception e) { Console.Error.WriteLine(); Console.Error.WriteLine($"FAILED: {e.Message}"); Console.Error.WriteLine( "The [brokerd] lines above are the facade's own diagnostics and usually say why."); return 1; } } private static async Task RunScenarioAsync( string brokerPath, string sessionName, string image, string containerName, string repo, int iterations, bool sleepInit, bool verbose) { await using var broker = BrokerClient.Spawn(brokerPath); broker.Verbose = verbose; // Pull progress is high-rate; collapse it to one line per phase change so the transcript // stays readable but a stalled pull is still visible. var lastStatus = ""; broker.Notification += (method, args) => { switch (method) { case "image.pullProgress": var status = args.TryGetProperty("status", out var s) ? s.GetString() ?? "" : ""; if (status != lastStatus) { lastStatus = status; Console.WriteLine($" pull: {status}"); } break; case "session.down": Console.WriteLine($" !! session.down: {args}"); break; } }; Step("hello"); var hello = await broker.CallAsync("hello"); var capabilities = hello.GetProperty("capabilities").EnumerateArray() .Select(c => c.GetString()).ToList(); Console.WriteLine($" brokerd {hello.GetProperty("brokerVersion").GetString()}, " + $"wslc {hello.GetProperty("wslcVersion").GetString() ?? "(absent)"}"); Console.WriteLine($" capabilities: {string.Join(", ", capabilities)}"); // These are the D13 capability flags; say plainly which arm answered so the run is // self-describing rather than needing the doc open beside it. Console.WriteLine(capabilities.Contains("recover") ? " → D13 Tier 1 bound: an orphaned session will be recovered automatically" : " → D13 Tier 1 did NOT bind: a broker restart will need `wsl --shutdown`"); Step("components.missing"); var missing = (await broker.CallAsync("components.missing")).GetProperty("flags") .EnumerateArray().Select(c => c.GetString()).ToList(); if (missing.Count > 0) throw new InvalidOperationException( $"this machine cannot run wslc yet — missing {string.Join(", ", missing)}. " + "See windows/spikes/README.md for the per-component remedy."); Console.WriteLine(" none missing"); Step("session.ensure"); var dataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Nucleic", "spike", "wslc"); Directory.CreateDirectory(dataDir); var gateway = (await broker.CallAsync("session.ensure", new { name = sessionName, dataDir, cpu = 4, memoryMB = 8192 }, 300)) .GetProperty("gateway").GetString(); // §5's control plane depends on this address entirely, and no wslc API surfaces it — // it comes from the WSL vEthernet adapter. If it is empty or a loopback, the control // plane cannot work and M1 (b) has its answer early. Console.WriteLine($" host gateway (guest→host, §5): {gateway}"); Step($"image.pull {image}"); var pullWatch = Stopwatch.StartNew(); await broker.CallAsync("image.pull", new { @ref = image }, 1800); Console.WriteLine($" pulled in {pullWatch.Elapsed.TotalSeconds:F1}s"); Step($"container.create {containerName}"); var create = new Dictionary { ["name"] = containerName, ["image"] = image, ["volumes"] = new[] { new { host = repo, guest = "/work", ro = false } }, ["networkingMode"] = "Bridged", ["hostname"] = "nucleic-spike", ["env"] = new Dictionary { ["NUCLEIC_SPIKE"] = "1" }, }; // Mirror WslcContainerEngine: hydrangeaOS images carry no default CMD (wslc answers "no command // specified"), so the engine always names init explicitly — `hydrangeaos-init` is PID 1 per // docs/HYDRANGEAOS.md §5, doing zombie reaping, signal forwarding and, with HYDRANGEAOS_BRIDGE=1, // supervising control-bridge.js. `--sleep-init` swaps in a keepalive for stock images that // have no hydrangeaos-init, which is the case the engine handles with a probe (see §13.3). create["initArgv"] = sleepInit ? new[] { "/bin/sh", "-c", "sleep 3600" } : new[] { "/usr/sbin/hydrangeaos-init" }; await broker.CallAsync("container.create", create); await broker.CallAsync("container.start", new { name = containerName }); var state = (await broker.CallAsync("container.state", new { name = containerName })) .GetProperty("state").GetString(); Console.WriteLine($" state: {state}"); // Check here rather than letting the first exec fail `not_running`: a container whose // PID 1 exited looks identical to a container that failed to start, and the remedy // (supply a long-running init) is nothing like the remedy for a real start failure. if (state != "running") throw new InvalidOperationException( $"container is '{state}' immediately after start — its PID 1 exited. hydrangeaOS runs " + "hydrangeaos-init and stays up; a stock image (alpine's PID 1 is /bin/sh) does not. " + "Re-run with --sleep-init to give it a long-running init process."); Step("exec: stdio round-trip"); var (code, output) = await ExecAsync(broker, containerName, ["/bin/sh", "-c", "echo hello from \"$(uname -n)\"; echo to-stderr 1>&2"]); Console.WriteLine($" exit {code}: {output.Trim()}"); Step("exec: uid drop (setpriv wrapper, §3.2)"); // ProcessSettings has no uid/gid, so the facade wraps argv in setpriv. A BusyBox image has // a setpriv that cannot change uid, and the facade now REFUSES rather than silently // running the agent as root — so `unsupported` here is correct behaviour on such an image, // not a failure of the run. try { var (idCode, idOut) = await ExecAsync( broker, containerName, ["/bin/sh", "-c", "id -u; id -g"], uid: 501, gid: 501); Console.WriteLine(idCode == 0 ? $" uid/gid inside container: {idOut.Replace("\n", "/").Trim('/')}" : $" ran but reported failure (exit {idCode}): {idOut.Trim()}"); } catch (BrokerError e) when (e.Kind == "unsupported") { Console.WriteLine($" REFUSED (correctly): {e.Message}"); Console.WriteLine(" → expected on a BusyBox image; hydrangeaOS ships util-linux."); } Step("the mounted worktree"); var (lsCode, lsOut) = await ExecAsync(broker, containerName, ["/bin/sh", "-c", "ls /work | head -5; echo ---; test -d /work/.git && echo 'git repo present'"]); Console.WriteLine($" exit {lsCode}\n{Indent(lsOut)}"); await ProbeControlPlaneAsync(broker, containerName, gateway); await MeasureNinePAsync(broker, containerName, iterations); Step("container.stats (cgroup read — no GetStatistics() exists)"); var stats = (await broker.CallAsync("container.stats", new { name = containerName })) .GetProperty("stats"); Console.WriteLine(stats.ValueKind == JsonValueKind.Null ? " null (container not running?)" : $" {stats}"); Step("teardown"); await broker.CallAsync("container.stop", new { name = containerName, signal = 15, graceMs = 10000 }); await broker.CallAsync("container.delete", new { name = containerName, force = true }); Console.WriteLine(" stopped and deleted"); } /// /// M1 (b), and the last thing M2 waits on: can a container reach the host at the gateway? /// /// Every agent session depends on this. `control-bridge.js` forwards guest loopback 9099 to /// `NUCLEIC_CONTROL_HOST/PORT`, which is where `MCPApprovalServer` serves approvals, the git /// and gh interceptor endpoints, and the hydrashell shell reports (§5, §1.4). If the guest cannot /// open a TCP connection to the host on that address, none of it works and no agent can run. /// /// This is the reachability question in isolation: a bare TCP round trip, bound **only** to /// the WSL-facing address and never `0.0.0.0`, which is the posture §5 requires and therefore /// the posture worth testing. It answers it under whatever firewall policy the machine /// actually has — the variable §5 step 5 flags and cannot predict. /// private static async Task ProbeControlPlaneAsync( BrokerClient broker, string container, string? gateway) { Step("§5 control plane: can the guest reach the host at the gateway?"); if (!System.Net.IPAddress.TryParse(gateway, out var address)) { Console.WriteLine($" no usable gateway address ('{gateway}') — cannot test"); return; } var listener = new System.Net.Sockets.TcpListener(address, 0); try { listener.Start(); } catch (System.Net.Sockets.SocketException e) { // Binding the WSL-facing address is what MCPApprovalServer will do, so a failure here // is a finding about §5 rather than about this spike. Console.WriteLine($" could not bind {gateway}:0 — {e.SocketErrorCode}: {e.Message}"); return; } var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; Console.WriteLine($" host listening on {gateway}:{port} (interface-scoped, not 0.0.0.0)"); // Answer with a minimal HTTP response as well as reading the request, so both `nc` and // `wget` work as the guest client — the real bridge speaks HTTP to /mcp. var received = Task.Run(async () => { using var client = await listener.AcceptTcpClientAsync(); using var stream = client.GetStream(); var buffer = new byte[512]; var read = await stream.ReadAsync(buffer); const string body = "NUCLEIC-CONTROL-OK"; var response = "HTTP/1.1 200 OK\r\n" + $"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n{body}"; await stream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(response)); await stream.FlushAsync(); return System.Text.Encoding.UTF8.GetString(buffer, 0, read); }); try { // hydrangeaOS has neither `nc` nor `wget` — it has **node**, since control-bridge.js is the // real client on this path. Passed as argv with no shell, so nothing here needs // quoting, and the payload is a bare "PING" because the host side replies to whatever // it reads (no CRLF handling to get wrong). var script = "const net=require('net');const s=net.connect(" + port + ",'" + gateway + "');" + "let d='';s.setTimeout(5000,()=>{console.error('TIMEOUT');process.exit(4)});" + "s.on('connect',()=>s.write('PING'));s.on('data',c=>d+=c);" + "s.on('close',()=>{process.stdout.write(d);process.exit(0)});" + "s.on('error',e=>{console.error('CONNECT-ERROR '+e.code);process.exit(3)});"; string[] argv = ["node", "-e", script]; Console.WriteLine($" guest: node -e "); var (code, output) = await ExecAsync(broker, container, argv, timeoutSeconds: 60); // A missing client tool is NOT a blocked connection, and reporting it as one sends the // reader off to inspect firewall rules for no reason. An earlier version of this probe // did exactly that on hydrangeaOS (§13.3). if (code == 127 || output.Contains("command not found") || output.Contains("not found")) { Console.WriteLine($" INCONCLUSIVE — no usable client in this image (exit {code}): " + output.Trim()); Console.WriteLine(" → says nothing about reachability. Use an image with node, " + "nc or wget."); return; } if (output.Contains("NUCLEIC-CONTROL-OK")) { var got = await received.WaitAsync(TimeSpan.FromSeconds(5)); Console.WriteLine(" REACHABLE — the guest completed a TCP round trip to the host."); Console.WriteLine($" host saw: {got.Split('\n')[0].Trim()}"); Console.WriteLine(" → §5's gateway-TCP control plane works on this machine under " + "its current firewall policy. M2 is unblocked."); } else { Console.WriteLine($" NOT REACHABLE (exit {code}): {output.Trim()}"); Console.WriteLine(" → this is the §5 step-5 firewall case. The control plane " + "cannot work until it is resolved, so no agent can run:"); Console.WriteLine(" • check the Hyper-V firewall policy for the WSL vSwitch"); Console.WriteLine(" • `New-NetFirewallRule -DisplayName 'Nucleic control' " + $"-Direction Inbound -LocalAddress {gateway} -Protocol TCP -Action Allow`"); Console.WriteLine(" • if it stays blocked, §5's AF_HYPERV fallback stops being " + "upside and becomes required — but §13.2 found no client route to the VM GUID, " + "so that needs a source outside wslc (HCS enumeration)."); } } finally { listener.Stop(); } } /// /// §15's top unquantified risk: D8 puts repos on NTFS and bind-mounts them, so every git and /// npm operation crosses 9P. The only honest measurement is the same work on both sides of /// the mount, in the same container, on the same repo — so this copies the worktree to the /// container's own ext4 and runs the identical commands there. /// private static async Task MeasureNinePAsync(BrokerClient broker, string container, int iterations) { // `find -type f` rather than `git status`, because it needs only busybox and measures the // same thing that makes git slow over a mount: a full lstat() traversal of the tree. Using // git would make the number depend on the image having git, which is what derailed the // first attempt at this measurement. var probe = "find . -type f | wc -l"; var (gitCode, _) = await ExecAsync(broker, container, ["/bin/sh", "-c", "command -v git"]); if (gitCode == 0) { probe = "git status --porcelain >/dev/null"; Console.WriteLine(" (git present — measuring `git status`)"); } else { Console.WriteLine(" (no git in this image — measuring `find -type f`, which is the " + "lstat traversal that dominates `git status`)"); } Step($"9P vs ext4 — `{probe}` x{iterations} (§15 risk, D8)"); var mounted = await TimeCommandAsync(broker, container, $"cd /work && {probe}", iterations); Report("/work (NTFS via 9P)", mounted); Console.WriteLine(" copying the worktree to container-local ext4…"); var copyWatch = Stopwatch.StartNew(); var (copyCode, copyOut) = await ExecAsync(broker, container, ["/bin/sh", "-c", "rm -rf /tmp/ext4 && cp -a /work /tmp/ext4 && echo ok"], timeoutSeconds: 1800); if (copyCode != 0) { Console.WriteLine($" copy failed (exit {copyCode}): {copyOut.Trim()} — skipping the ext4 leg"); return; } Console.WriteLine($" copied in {copyWatch.Elapsed.TotalSeconds:F1}s"); var local = await TimeCommandAsync(broker, container, $"cd /tmp/ext4 && {probe}", iterations); Report("/tmp/ext4 (container-local)", local); // Reads are only half the story: `npm install` writes tens of thousands of small files, // which is the case §15 singles out and which a traversal does not exercise at all. Step("9P vs ext4 — writing 2000 small files (the `npm install` shape)"); const string write = "rm -rf wbench && mkdir wbench && cd wbench && " + "i=0; while [ $i -lt 2000 ]; do echo x > f$i; i=$((i+1)); done && cd .. && rm -rf wbench"; var mountedWrite = await TimeCommandAsync(broker, container, $"cd /work && {write}", 1); Report("/work (NTFS via 9P)", mountedWrite); var localWrite = await TimeCommandAsync(broker, container, $"cd /tmp && {write}", 1); Report("/tmp (container-local)", localWrite); if (mountedWrite.Count > 0 && localWrite.Count > 0) Console.WriteLine($" → writes are {Median(mountedWrite) / Math.Max(1, Median(localWrite)):F1}x " + $"slower across the mount ({Median(mountedWrite):F0}ms vs {Median(localWrite):F0}ms " + "for 2000 files)"); if (mounted.Count > 0 && local.Count > 0) { var ratio = Median(mounted) / Math.Max(1, Median(local)); // Report BOTH, because they lead to different conclusions: a large ratio on a small // absolute time is tolerable, and that is the distinction the first `find`-based run // got wrong by having no absolute figure worth quoting. Console.WriteLine($" → reads are {ratio:F1}x the local time " + $"({Median(mounted):F0}ms vs {Median(local):F0}ms per run)"); // Judged on ABSOLUTE latency, not ratio: what matters to a session is how long a // status takes, and a big multiple of a tiny number is still a tiny number. §15 says // surface the result for a decision rather than silently relocating repos, so none of // these branches change anything. var absolute = Median(mounted); Console.WriteLine(absolute switch { < 250 => " → tolerable: D8 stands as written.", < 2000 => " → a visible pause on every operation, but workable. D8 stands;\n" + " cache dirs (node_modules, build output) on a ContainerNamedVolume\n" + " per §15 recover most of it. Compare the write number above —\n" + " writes, not reads, are what npm install pays.", _ => " → too slow to ignore. Surface to the user (§15): the options are hot-dir\n" + " named volumes, or moving the clone into the session's ext4 with host\n" + " access over \\\\wsl$. Do not relocate repos silently.", }); } await ExecAsync(broker, container, ["/bin/sh", "-c", "rm -rf /tmp/ext4"]); } private static async Task> TimeCommandAsync( BrokerClient broker, string container, string script, int iterations) { var timings = new List(); for (var i = 0; i < iterations; i++) { var watch = Stopwatch.StartNew(); var (code, output) = await ExecAsync(broker, container, ["/bin/sh", "-c", script], timeoutSeconds: 600); watch.Stop(); if (code != 0) { Console.WriteLine($" command failed (exit {code}): {output.Trim()}"); return timings; } timings.Add(watch.Elapsed.TotalMilliseconds); } return timings; } private static void Report(string label, List timings) { if (timings.Count == 0) { Console.WriteLine($" {label}: no successful runs"); return; } Console.WriteLine($" {label}: median {Median(timings):F0}ms " + $"(min {timings.Min():F0}, max {timings.Max():F0}, n={timings.Count})"); } private static double Median(List values) { var sorted = values.Order().ToList(); return sorted.Count % 2 == 1 ? sorted[sorted.Count / 2] : (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2; } /// /// D13 Tier 1, live (docs/WINDOWS_PORT.md §13.2). Kill a broker with its session up — the /// exact §2.3 crash — then start a fresh one and ensure the same session name. Before Tier 1 /// this failed `session_exists` and needed a manual `wsl --shutdown`; it should now recover. /// private static async Task RunRecoveryAsync(string brokerPath, string sessionName) { Step("D13 Tier 1: kill the broker, then recover the orphaned session"); var dataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Nucleic", "spike", "wslc"); await using (var first = BrokerClient.Spawn(brokerPath)) { await first.CallAsync("hello"); await first.CallAsync("session.ensure", new { name = sessionName, dataDir }, 300); Console.WriteLine(" session up; killing brokerd without shutdown…"); first.Kill(); } // The session is deliberately left running: that is the orphan. await Task.Delay(2000); await using var second = BrokerClient.Spawn(brokerPath); var hello = await second.CallAsync("hello"); var canRecover = hello.GetProperty("capabilities").EnumerateArray() .Any(c => c.GetString() == "recover"); try { var gateway = (await second.CallAsync( "session.ensure", new { name = sessionName, dataDir }, 300)) .GetProperty("gateway").GetString(); Console.WriteLine($" RECOVERED — fresh session up, gateway {gateway}"); Console.WriteLine(" → a broker crash no longer strands the sandbox."); await second.CallAsync("session.terminate"); } catch (BrokerError e) when (e.Kind == "session_exists") { Console.WriteLine($" NOT recovered: {e.Message}"); Console.WriteLine(canRecover ? " → Tier 1 bound but did not clear the orphan. This is the bug to chase:\n" + " read the [brokerd] lines above for the OpenSessionByName/Terminate result." : " → expected: Tier 1 never bound on this machine (see the hello above)."); Console.WriteLine(" Clear it manually with `wsl --shutdown`."); } } // MARK: - Helpers /// Run argv to completion, collecting stdout+stderr from the broker's notifications /// exactly as `WslcProcessHandle` does. private static async Task<(int Code, string Output)> ExecAsync( BrokerClient broker, string container, string[] argv, int? uid = null, int? gid = null, int timeoutSeconds = 300) { var output = new MemoryStream(); var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Deliberately NOT filtered by procId. // // The broker now guarantees the `proc.exec` response precedes any notification, but that // is not sufficient for a client: completing the response's TaskCompletionSource only // SCHEDULES the awaiting continuation, so the reader thread can dispatch the very next // line — proc.stdout, or proc.exit — before the continuation has recorded the procId. // Filtering on a not-yet-assigned procId silently drops the exit and hangs forever, which // is exactly how this spike hung twice (docs/WINDOWS_PORT.md §13.3). // // Safe here because the spike runs one process at a time and awaits each to completion. // A real client cannot take this shortcut: it must buffer notifications for procIds it // has not yet learned. Worth checking `WslcBrokerClient.swift` for the same race. void OnNotification(string method, JsonElement args) { if (!args.TryGetProperty("procId", out _)) return; switch (method) { case "proc.stdout": case "proc.stderr": var chunk = Convert.FromBase64String(args.GetProperty("b64").GetString()!); lock (output) output.Write(chunk, 0, chunk.Length); break; case "proc.exit": exited.TrySetResult(args.GetProperty("code").GetInt32()); break; } } broker.Notification += OnNotification; try { object spec = uid is null ? new { container, argv, tty = false } : new { container, argv, uid, gid = gid ?? uid, tty = false }; await broker.CallAsync("proc.exec", spec); var code = await exited.Task.WaitAsync(TimeSpan.FromSeconds(timeoutSeconds)); lock (output) return (code, System.Text.Encoding.UTF8.GetString(output.ToArray())); } finally { broker.Notification -= OnNotification; } } /// Locate the broker built with -p:UseWslc=true. The TFM is windows-specific there, /// so glob rather than hardcoding a path that moves with the SDK pin. private static string? FindBroker() { var here = new DirectoryInfo(AppContext.BaseDirectory); for (var dir = here; dir is not null; dir = dir.Parent) { var candidate = Path.Combine(dir.FullName, "windows", "NucleicBroker", "bin"); if (!Directory.Exists(candidate)) continue; return Directory .EnumerateFiles(candidate, "nucleic-brokerd.exe", SearchOption.AllDirectories) // Prefer the windows-TFM build: that is the one with USE_WSLC compiled in. .OrderByDescending(p => p.Contains("windows10.0")) .ThenByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); } return null; } private static void Step(string title) { Console.WriteLine(); Console.WriteLine($"==> {title}"); } private static string Indent(string text) => string.Join("\n", text.Split('\n').Select(l => " " + l)); private static string? Arg(string[] args, string flag) { var i = Array.IndexOf(args, flag); return i >= 0 && i + 1 < args.Length && !args[i + 1].StartsWith("--") ? args[i + 1] : null; } }