diff --git a/PROGRESS.md b/PROGRESS.md
index 2138eeb..90d2e52 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -10,6 +10,17 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
+- **Done (2026-09-16): Managed CLI and cross-generation client exit.** Added `VoiceCat.Cli`
+ with interactive channel text, TOFU, channel selection and deterministic headless
+ text/tone verification. Independent process tests run two managed CLIs in channels 1 and 2
+ and prove text plus decoded bidirectional voice. A separate process test pairs it with the
+ existing C++ `vccli` in channel 2 and verifies text and voice in both directions. This
+ completes the behavioral Phase 6 criterion. **Verified:** 193 managed tests with all
+ conformance variables enabled, locked restore, package audit and 29/29 CTest tests.
+ **Next:** resume broad server production
+ deployment (Linux publish/container/service and soak), then the remaining manual Windows
+ listen/NVDA release checks before beginning the C# AppKit port.
+
- **Done (2026-09-16): Managed client/audio and Windows cutover checkpoint.** Added
`VoiceCat.Core` with TOFU-gated TLS, correlated concurrent requests, immutable snapshots,
reconnects, bounded client events, UDP binding and authenticated encrypted media. Added
diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md
index 148a5e5..c7e1629 100644
--- a/docs/porting-to-dotnet.md
+++ b/docs/porting-to-dotnet.md
@@ -817,11 +817,12 @@ the required ten-minute Windows/macOS listen test is still a manual release gate
conversation through the C# server**, and a C# `vccli` interoperates with a C++ `vccli` on
the same server. This is the full M0–M3 criterion re-proven end to end.
-**Checkpoint (2026-09-16):** `VoiceCat.Core` implements TOFU-gated TLS, concurrent correlated
+**Complete (2026-09-16):** `VoiceCat.Core` implements TOFU-gated TLS, concurrent correlated
requests, snapshots/events, reconnects, encrypted UDP binding and send/receive stream
lifecycle. Two managed clients exchange text and decoded PCM through the managed server.
-The remaining Phase 6 item is the managed console client and its explicit C++ CLI
-interoperability scenario.
+`VoiceCat.Cli` supports interactive channel text and deterministic headless text/voice runs.
+Process tests prove two managed CLIs converse with decoded voice in both configured channels,
+and a managed CLI exchanges text and bidirectional voice with the existing C++ CLI.
---
diff --git a/dotnet/README.md b/dotnet/README.md
index 942b6c3..fbb2ed5 100644
--- a/dotnet/README.md
+++ b/dotnet/README.md
@@ -1,9 +1,8 @@
# VoiceCat .NET rewrite
-The first slice targets .NET 10: protobuf, control framing, voice headers, and media
-encryption, TLS 1.3, persisted TOFU pins, server credentials, and an initial managed
-control server. Media relay, client state, audio, and UI migration are next. The existing
-C++ implementation remains the conformance oracle.
+The .NET 10 implementation includes protocol, TLS/TOFU and media crypto, the control and
+UDP server, client state, real-time audio, a console client and the Windows application.
+The existing C++ implementation remains the conformance oracle while Apple clients move.
Codec/DSP wrappers now cover Opus, DRED recovery, RNNoise, and energy VAD. Build
the desktop native library before running their tests (CMake and a C compiler required):
@@ -44,6 +43,16 @@ dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
```
+Run the managed console client interactively:
+
+```powershell
+dotnet run --project dotnet/src/VoiceCat.Cli -- --host 127.0.0.1 --port 8384 --nickname Alice --trust-first
+```
+
+Plain input sends channel text; `/join ID` changes channel and `/quit` exits. Headless
+conformance options include `--voice`, `--send-text`, `--expect-text`, `--expect-voice`,
+`--start-delay-ms` and `--timeout-seconds`; `--help` lists the complete syntax.
+
Dependencies are pinned in project files and lock files. Generated protobuf is build
output; the schema remains `core/proto/voicecat.proto`. Production dependencies are
Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only
diff --git a/dotnet/VoiceCat.slnx b/dotnet/VoiceCat.slnx
index abd5ec4..62456af 100644
--- a/dotnet/VoiceCat.slnx
+++ b/dotnet/VoiceCat.slnx
@@ -7,6 +7,7 @@
+
diff --git a/dotnet/src/VoiceCat.Cli/Program.cs b/dotnet/src/VoiceCat.Cli/Program.cs
new file mode 100644
index 0000000..2911593
--- /dev/null
+++ b/dotnet/src/VoiceCat.Cli/Program.cs
@@ -0,0 +1,115 @@
+using System.Text.Json;
+using VoiceCat.Audio;
+using VoiceCat.Core;
+using Voicecat.V1;
+
+return await CliCommand.RunAsync(args);
+
+public static class CliCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ try
+ {
+ var options = Options.Parse(args);
+ await using var client = new VoiceCatClient("VoiceCat.Cli", "0.1.0", options.Pins);
+ client.ConnectionStateChanged += state => Print("state", state.ToString());
+ long energy = 0;
+ client.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref energy, sum); };
+ await client.ConnectAsync(options.Host, options.Port, (challenge, _) =>
+ {
+ Print("identity", challenge.CertificateFingerprint, new { status = challenge.Status.ToString() });
+ return ValueTask.FromResult(options.TrustFirst && challenge.Status == VoiceCat.Crypto.TofuStatus.FirstConnect);
+ });
+ AuthResult auth = await client.AuthenticateGuestAsync(options.Nickname);
+ if (!auth.Ok) throw new InvalidOperationException(auth.Error);
+ Print("authenticated", options.Nickname, new { userId = auth.Self.Id });
+ if (options.Channel != 1)
+ {
+ var joined = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = options.Channel } })).JoinChannelResult;
+ if (!joined.Ok) throw new InvalidOperationException(joined.Error);
+ }
+ using var stopped = new CancellationTokenSource();
+ Console.CancelKeyPress += (_, e) => { e.Cancel = true; stopped.Cancel(); };
+ Task events = ObserveAsync(client, options.ExpectText, stopped.Token);
+ if (options.Voice)
+ {
+ VoiceSubscriptionResult subscribed = await client.SubscribeVoiceAsync();
+ if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error);
+ await client.StartStreamAsync(StreamKind.StreamMic, "CLI tone");
+ client.Audio.InputMode = AudioInputMode.AlwaysOn;
+ }
+ Print("ready", options.Nickname);
+ if (options.Delay > TimeSpan.Zero) await Task.Delay(options.Delay, stopped.Token);
+ if (options.SendText is not null)
+ client.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = options.Channel, Body = options.SendText, ClientMsgId = Guid.NewGuid().ToString("N") } });
+ Task? tone = options.Voice ? SendToneAsync(client, stopped.Token) : null;
+ if (options.OneShot)
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(stopped.Token);
+ deadline.CancelAfter(options.Timeout);
+ while ((!string.IsNullOrEmpty(options.ExpectText) && !SeenText) || (options.ExpectVoice && Interlocked.Read(ref energy) < 100000))
+ await Task.Delay(20, deadline.Token);
+ Print("complete", options.Nickname, new { voiceEnergy = Interlocked.Read(ref energy) });
+ if (options.Linger > TimeSpan.Zero) await Task.Delay(options.Linger, deadline.Token);
+ stopped.Cancel();
+ }
+ else await InteractiveAsync(client, stopped.Token);
+ if (tone is not null) try { await tone; } catch (OperationCanceledException) { }
+ try { await events; } catch (OperationCanceledException) { }
+ return 0;
+ }
+ catch (OperationCanceledException) { Console.Error.WriteLine("VoiceCat.Cli timed out or was cancelled."); return 2; }
+ catch (Exception exception) { Console.Error.WriteLine(exception.Message); return 1; }
+ }
+ private static volatile bool SeenText;
+ private static async Task ObserveAsync(VoiceCatClient client, string? expected, CancellationToken token)
+ {
+ await foreach (Envelope message in client.ReadEventsAsync(token))
+ {
+ if (message.TextMessage is { } text)
+ {
+ Print("text", text.Body, new { senderId = text.SenderId, channelId = text.TargetId });
+ if (expected is null || text.Body == expected) SeenText = true;
+ }
+ if (message.UserEvent is { } user) Print("user", user.Kind.ToString(), new { userId = user.User?.Id ?? user.LeftId });
+ }
+ }
+ private static async Task SendToneAsync(VoiceCatClient client, CancellationToken token)
+ {
+ short[] pcm = new short[960];
+ for (int frame = 0; frame < 150 && !token.IsCancellationRequested; frame++)
+ {
+ for (int i = 0; i < pcm.Length; i++) pcm[i] = (short)(Math.Sin((frame * 960 + i) * Math.PI * 880 / 48000) * 8000);
+ foreach (StreamInfo stream in client.LocalStreams) client.Audio.FeedPcm(stream.StreamId, pcm, 1);
+ await Task.Delay(20, token);
+ }
+ }
+ private static async Task InteractiveAsync(VoiceCatClient client, CancellationToken token)
+ {
+ while (!token.IsCancellationRequested && await Console.In.ReadLineAsync(token) is { } line)
+ {
+ if (line == "/quit") return;
+ if (line.StartsWith("/join ") && uint.TryParse(line[6..], out uint channel)) await client.RequestAsync(new() { JoinChannel = new() { ChannelId = channel } }, token);
+ else client.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = client.Authentication?.Self.ChannelId ?? 1, Body = line, ClientMsgId = Guid.NewGuid().ToString("N") } });
+ }
+ }
+ private static void Print(string type, string value, object? extra = null) => Console.WriteLine(JsonSerializer.Serialize(new { type, value, extra }));
+
+ private sealed record Options(string Host, ushort Port, string Nickname, string Pins, uint Channel, bool TrustFirst, bool Voice, bool ExpectVoice, string? SendText, string? ExpectText, TimeSpan Delay, TimeSpan Linger, TimeSpan Timeout)
+ {
+ internal bool OneShot => SendText is not null || ExpectText is not null || ExpectVoice;
+ internal static Options Parse(string[] args)
+ {
+ string Value(string name, string fallback) { int i = Array.IndexOf(args, name); return i >= 0 && i + 1 < args.Length ? args[i + 1] : fallback; }
+ bool Has(string name) => args.Contains(name, StringComparer.OrdinalIgnoreCase);
+ if (Has("--help")) { Console.WriteLine("VoiceCat.Cli --host HOST --port PORT --nickname NAME [--trust-first] [--channel ID] [--voice] [--send-text TEXT] [--expect-text TEXT] [--expect-voice] [--start-delay-ms N]"); Environment.Exit(0); }
+ return new(Value("--host", "127.0.0.1"), ushort.Parse(Value("--port", "8384")), Value("--nickname", Environment.UserName),
+ Value("--pins", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceCat", "cli-tofu.txt")), uint.Parse(Value("--channel", "1")), Has("--trust-first"), Has("--voice") || Has("--expect-voice"), Has("--expect-voice"),
+ Array.IndexOf(args, "--send-text") is int send and >= 0 && send + 1 < args.Length ? args[send + 1] : null,
+ Array.IndexOf(args, "--expect-text") is int expect and >= 0 && expect + 1 < args.Length ? args[expect + 1] : null,
+ TimeSpan.FromMilliseconds(int.Parse(Value("--start-delay-ms", "0"))), TimeSpan.FromMilliseconds(int.Parse(Value("--linger-ms", "1000"))),
+ TimeSpan.FromSeconds(int.Parse(Value("--timeout-seconds", "15"))));
+ }
+ }
+}
diff --git a/dotnet/src/VoiceCat.Cli/VoiceCat.Cli.csproj b/dotnet/src/VoiceCat.Cli/VoiceCat.Cli.csproj
new file mode 100644
index 0000000..82ea8ae
--- /dev/null
+++ b/dotnet/src/VoiceCat.Cli/VoiceCat.Cli.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
diff --git a/dotnet/src/VoiceCat.Cli/packages.lock.json b/dotnet/src/VoiceCat.Cli/packages.lock.json
new file mode 100644
index 0000000..c313b73
--- /dev/null
+++ b/dotnet/src/VoiceCat.Cli/packages.lock.json
@@ -0,0 +1,51 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "BouncyCastle.Cryptography": {
+ "type": "Transitive",
+ "resolved": "2.6.2",
+ "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ },
+ "Google.Protobuf": {
+ "type": "Transitive",
+ "resolved": "3.36.1",
+ "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
+ },
+ "voicecat.audio": {
+ "type": "Project",
+ "dependencies": {
+ "VoiceCat.Codec": "[1.0.0, )",
+ "VoiceCat.Dsp": "[1.0.0, )",
+ "VoiceCat.Protocol": "[1.0.0, )"
+ }
+ },
+ "voicecat.codec": {
+ "type": "Project"
+ },
+ "voicecat.core": {
+ "type": "Project",
+ "dependencies": {
+ "VoiceCat.Audio": "[1.0.0, )",
+ "VoiceCat.Crypto": "[1.0.0, )"
+ }
+ },
+ "voicecat.crypto": {
+ "type": "Project",
+ "dependencies": {
+ "BouncyCastle.Cryptography": "[2.6.2, )",
+ "VoiceCat.Protocol": "[1.0.0, )"
+ }
+ },
+ "voicecat.dsp": {
+ "type": "Project"
+ },
+ "voicecat.protocol": {
+ "type": "Project",
+ "dependencies": {
+ "Google.Protobuf": "[3.36.1, )"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/dotnet/tests/VoiceCat.Tests/ManagedCliTests.cs b/dotnet/tests/VoiceCat.Tests/ManagedCliTests.cs
new file mode 100644
index 0000000..1ec6231
--- /dev/null
+++ b/dotnet/tests/VoiceCat.Tests/ManagedCliTests.cs
@@ -0,0 +1,69 @@
+using System.Diagnostics;
+using static VoiceCat.Tests.ServerTests;
+
+namespace VoiceCat.Tests;
+
+public class ManagedCliTests
+{
+ [Fact]
+ public async Task ManagedCliInteroperatesWithExistingCppCli()
+ {
+ string? nativeCli = Environment.GetEnvironmentVariable("VOICECAT_VCCLI");
+ if (string.IsNullOrEmpty(nativeCli)) return; // Full conformance runs set this explicitly.
+ await using var fixture = new ServerFixture();
+ string cli = Path.Combine(FindRoot(), "dotnet", "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
+ using Process managed = Start(cli, fixture, "Managed", "Managed checkpoint", "Native checkpoint", 2);
+ await Task.Delay(750); // Native vccli sends its one-shot text immediately after auth.
+ var nativeStart = new ProcessStartInfo(nativeCli) { WorkingDirectory = fixture.Directory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true };
+ foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Native",
+ "--channel", "2", "--text", "Native checkpoint", "--test-tone-ms", "5000" }) nativeStart.ArgumentList.Add(argument);
+ using Process native = Process.Start(nativeStart)!;
+ Task managedOut = managed.StandardOutput.ReadToEndAsync(), managedError = managed.StandardError.ReadToEndAsync();
+ Task nativeOut = native.StandardOutput.ReadToEndAsync(), nativeError = native.StandardError.ReadToEndAsync();
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
+ await Task.WhenAll(managed.WaitForExitAsync(timeout.Token), native.WaitForExitAsync(timeout.Token));
+ string mout = await managedOut, nout = await nativeOut;
+ Assert.True(managed.ExitCode == 0, await managedError + Environment.NewLine + mout);
+ Assert.True(native.ExitCode == 0, await nativeError + Environment.NewLine + nout);
+ Assert.Contains("Native checkpoint", mout); Assert.Contains("Managed checkpoint", nout);
+ Assert.Contains("[test-tone] received=", nout); Assert.DoesNotContain("\"voiceEnergy\":0", mout);
+ }
+
+ [Theory]
+ [InlineData(1u)]
+ [InlineData(2u)]
+ public async Task TwoManagedCliProcessesExchangeTextAndDecodedVoice(uint channel)
+ {
+ await using var fixture = new ServerFixture();
+ string root = FindRoot();
+ string cli = Path.Combine(root, "dotnet", "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
+ Assert.True(File.Exists(cli), $"Managed CLI was not built at {cli}.");
+ using Process alice = Start(cli, fixture, "Alice", "Alice says hello", "Bob says hello", channel);
+ using Process bob = Start(cli, fixture, "Bob", "Bob says hello", "Alice says hello", channel);
+ Task aliceOut = alice.StandardOutput.ReadToEndAsync(); Task aliceError = alice.StandardError.ReadToEndAsync();
+ Task bobOut = bob.StandardOutput.ReadToEndAsync(); Task bobError = bob.StandardError.ReadToEndAsync();
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
+ await Task.WhenAll(alice.WaitForExitAsync(timeout.Token), bob.WaitForExitAsync(timeout.Token));
+ string aout = await aliceOut, bout = await bobOut;
+ Assert.True(alice.ExitCode == 0, await aliceError + Environment.NewLine + aout);
+ Assert.True(bob.ExitCode == 0, await bobError + Environment.NewLine + bout);
+ Assert.Contains("Bob says hello", aout); Assert.Contains("Alice says hello", bout);
+ Assert.Contains("\"type\":\"complete\"", aout); Assert.Contains("\"type\":\"complete\"", bout);
+ Assert.DoesNotContain("\"voiceEnergy\":0", aout); Assert.DoesNotContain("\"voiceEnergy\":0", bout);
+ }
+
+ private static Process Start(string cli, ServerFixture fixture, string name, string send, string expect, uint channel)
+ {
+ var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ foreach (string argument in new[] { cli, "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nickname", name,
+ "--pins", Path.Combine(fixture.Directory, name + ".cli.pins"), "--trust-first", "--channel", channel.ToString(), "--voice", "--expect-voice", "--send-text", send,
+ "--expect-text", expect, "--start-delay-ms", "1500", "--timeout-seconds", "20" }) start.ArgumentList.Add(argument);
+ return Process.Start(start) ?? throw new InvalidOperationException("Could not start managed CLI.");
+ }
+ private static string FindRoot()
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "CMakePresets.json"))) directory = directory.Parent;
+ return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
+ }
+}
diff --git a/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj b/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj
index 1a0a91a..ef72bf1 100644
--- a/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj
+++ b/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj
@@ -6,6 +6,7 @@
+