Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions windows/.github/workflows/windows-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Windows Release

on:
workflow_dispatch:
push:
tags: ["windows-v*"]

jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Build and test
shell: pwsh
run: |
dotnet build CodeIsland.Windows/CodeIsland.Windows.csproj -c Release
dotnet build CodeIsland.Bridge/CodeIsland.Bridge.csproj -c Release
dotnet run --project CodeIsland.Windows.Smoke/CodeIsland.Windows.Smoke.csproj -c Release
- name: Package
shell: pwsh
run: pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/build-release.ps1
- name: Verify package
shell: pwsh
run: |
$manifest = Get-Content artifacts/release-manifest.json -Raw | ConvertFrom-Json
$archive = Join-Path artifacts $manifest.archive
if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $manifest.sha256) { throw "Archive hash mismatch" }
if (-not (Test-Path artifacts/staging/CodeIsland.Windows.exe)) { throw "Desktop executable missing" }
if (-not (Test-Path artifacts/staging/CodeIsland.Bridge.exe)) { throw "Bridge executable missing" }
- uses: actions/upload-artifact@v4
with:
name: codeisland-windows
path: |
artifacts/*.zip
artifacts/release-manifest.json
9 changes: 9 additions & 0 deletions windows/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.dotnet/
**/bin/
**/obj/
TestResults/
*.user
*.suo
.vs/
artifacts/
.codegraph/
369 changes: 369 additions & 0 deletions windows/CodeIsland-Windows-Complete-Development-Plan.md

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions windows/CodeIsland.Bluetooth/BuddyProtocol.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Text;
using CodeIsland.Core;

namespace CodeIsland.Bluetooth;

public static class BuddyProtocol
{
public static readonly Guid ServiceUuid = Guid.Parse("0000beef-0000-1000-8000-00805f9b34fb");
public static readonly Guid WriteCharacteristicUuid = Guid.Parse("0000beef-0001-1000-8000-00805f9b34fb");
public static readonly Guid NotifyCharacteristicUuid = Guid.Parse("0000beef-0002-1000-8000-00805f9b34fb");
public const string AdvertisedDeviceName = "Buddy";
public const int HostIdLength = 6;
public const int MaxToolNameBytes = 17;
public const byte PairRequestMarker = 0xE0;
public const byte UnpairMarker = 0xE1;
public const byte WorkspaceMarker = 0xFC;
public const byte BrightnessMarker = 0xFE;
public const byte OrientationMarker = 0xFD;

public static byte[] EncodeAgent(AgentKind agent, SessionState state, string? toolName = null)
{
var mascot = ToMascot(agent);
var status = ToStatus(state);
var tool = TruncateUtf8(toolName, MaxToolNameBytes);
return [mascot, status, checked((byte)tool.Length), .. tool];
}

public static byte[] EncodeWorkspace(string? workspace)
{
var bytes = TruncateUtf8(workspace?.Trim(), 18);
return [WorkspaceMarker, checked((byte)bytes.Length), .. bytes];
}

public static byte[] EncodeBrightness(double percent)
{
var value = double.IsFinite(percent) ? Math.Clamp((int)Math.Round(percent), 10, 100) : 70;
return [BrightnessMarker, checked((byte)value)];
}

public static byte[] EncodeOrientation(bool down) => [OrientationMarker, down ? (byte)1 : (byte)0];
public static byte[] EncodePairRequest(ReadOnlySpan<byte> hostId) => EncodeHostFrame(PairRequestMarker, hostId);
public static byte[] EncodeUnpair(ReadOnlySpan<byte> hostId) => EncodeHostFrame(UnpairMarker, hostId);

public static BuddyUplinkEvent? DecodeUplink(ReadOnlySpan<byte> payload)
{
if (payload.IsEmpty) return null;
var value = payload[0];
if (value is >= 0xE0 and <= 0xE2)
return new BuddyUplinkEvent(BuddyUplinkKind.PairResponse, value);
if (value <= 15) return new BuddyUplinkEvent(BuddyUplinkKind.Focus, value);
if (value is >= 0xF0 and <= 0xF2)
return new BuddyUplinkEvent(BuddyUplinkKind.ControlCommand, value);
return null;
}

private static byte[] EncodeHostFrame(byte marker, ReadOnlySpan<byte> hostId)
{
if (hostId.Length != HostIdLength) throw new ArgumentException("Buddy host id must contain exactly 6 bytes.", nameof(hostId));
var result = new byte[1 + HostIdLength];
result[0] = marker;
hostId.CopyTo(result.AsSpan(1));
return result;
}

private static byte[] TruncateUtf8(string? value, int limit) =>
string.IsNullOrEmpty(value) ? [] : Encoding.UTF8.GetBytes(value).Take(limit).ToArray();

private static byte ToMascot(AgentKind agent) => agent switch
{
AgentKind.Claude => 0, AgentKind.Codex => 1, AgentKind.Gemini => 2, AgentKind.Cursor => 3,
AgentKind.Copilot => 4, AgentKind.Trae => 5, AgentKind.Qoder => 6, AgentKind.Factory => 7,
AgentKind.CodeBuddy => 8, AgentKind.OpenCode => 10, AgentKind.Kimi => 15,
_ => throw new ArgumentOutOfRangeException(nameof(agent), agent, "Agent has no Buddy mascot slot.")
};

private static byte ToStatus(SessionState state) => state switch
{
SessionState.Idle or SessionState.Completed or SessionState.Cancelled => 0,
SessionState.Running => 2,
SessionState.WaitingForPermission => 3,
SessionState.WaitingForAnswer => 4,
SessionState.Failed => 3,
_ => 1
};
}

public enum BuddyUplinkKind { Focus, ControlCommand, PairResponse }
public sealed record BuddyUplinkEvent(BuddyUplinkKind Kind, byte Value);

public enum BuddyControlCommand : byte
{
ApproveCurrentPermission = 0xF0,
DenyCurrentPermission = 0xF1,
SkipCurrentQuestion = 0xF2
}

public enum BuddyPairResponse : byte { Accepted = 0xE0, Rejected = 0xE1, Pending = 0xE2 }
13 changes: 13 additions & 0 deletions windows/CodeIsland.Bluetooth/CodeIsland.Bluetooth.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\CodeIsland.Core\CodeIsland.Core.csproj" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
3 changes: 3 additions & 0 deletions windows/CodeIsland.Bridge/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.Versioning;

[assembly: SupportedOSPlatform("windows")]
16 changes: 16 additions & 0 deletions windows/CodeIsland.Bridge/CodeIsland.Bridge.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\CodeIsland.Core\CodeIsland.Core.csproj" />
<ProjectReference Include="..\CodeIsland.Protocol\CodeIsland.Protocol.csproj" />
<ProjectReference Include="..\CodeIsland.Ipc\CodeIsland.Ipc.csproj" />
</ItemGroup>

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
157 changes: 157 additions & 0 deletions windows/CodeIsland.Bridge/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using CodeIsland.Core;
using CodeIsland.Ipc;
using CodeIsland.Protocol;

var mode = args.Length > 0 ? args[0].ToLowerInvariant() : "serve";
if (mode == "serve")
{
var machine = new SessionStateMachine();
await using var server = CreateServer(machine);
Console.WriteLine($"CodeIsland Bridge listening on {PipeEndpoint.Name()}");
await server.RunAsync();
}
else if (mode == "send" && args.Length >= 2)
{
var stdin = args.Contains("--stdin", StringComparer.OrdinalIgnoreCase);
var source = GetOption(args, "--source");
var eventName = GetOption(args, "--event");
var userSid = GetOption(args, "--user-sid");
var file = stdin ? null : args.Skip(1).FirstOrDefault(value => !value.StartsWith("--", StringComparison.Ordinal));
var input = stdin ? await Console.In.ReadToEndAsync() : await File.ReadAllTextAsync(file
?? throw new ArgumentException("send requires --stdin or an event JSON file."));
var agentEvent = ParseEvent(input, source, eventName);
await using var client = new PipeClient(userSid);
await client.ConnectWithRetryAsync(3, TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(150));
var response = await client.SendAsync(
new PipeMessage(PipeMessageType.Event, Guid.NewGuid().ToString("N"), Event: agentEvent),
agentEvent.Type is AgentEventType.PermissionRequest or AgentEventType.Question
? TimeSpan.FromHours(8)
: TimeSpan.FromSeconds(3));
Console.WriteLine(HookResponse(response, agentEvent, source));
}
else if (mode == "self-test")
{
var permissionEvent = new AgentEvent("permission-1", "session-1", AgentKind.Codex,
AgentEventType.PermissionRequest, DateTimeOffset.UtcNow);
foreach (var (action, behavior) in new[]
{
(UserAction.Approve, "allow"),
(UserAction.AlwaysAllow, "always"),
(UserAction.Deny, "deny")
})
{
var hookResponse = HookResponse(new PipeMessage(PipeMessageType.ActionResponse, "response-1",
AckFor: permissionEvent.EventId, Action: action), permissionEvent, "codex");
using var hookDocument = JsonDocument.Parse(hookResponse);
var actual = hookDocument.RootElement.GetProperty("hookSpecificOutput").GetProperty("decision")
.GetProperty("behavior").GetString();
if (actual != behavior) throw new InvalidOperationException($"Expected Codex behavior {behavior}, got {actual}.");
}

using var stop = new CancellationTokenSource();
var machine = new SessionStateMachine();
await using var server = CreateServer(machine);
var serverTask = server.RunAsync(stop.Token);
await using var client = new PipeClient();
await client.ConnectWithRetryAsync(10, TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(50));

await ExpectAck(client, new PipeMessage(PipeMessageType.Hello, "hello-1"));
var testEvent = new AgentEvent("event-1", "session-1", AgentKind.Codex,
AgentEventType.SessionStart, DateTimeOffset.UtcNow, Environment.CurrentDirectory, "IPC self-test");
var serializedEvent = JsonSerializer.Serialize(testEvent, CreateEventJsonOptions());
testEvent = ParseEvent(serializedEvent, "codex", "SessionStart");
var rawCodex = ParseEvent("""
{"session_id":"codex-native-1","cwd":"C:\\work","tool_name":"shell","message":"running"}
""", "codex", "PreToolUse");
if (rawCodex.Agent != AgentKind.Codex || rawCodex.Type != AgentEventType.ToolStart
|| rawCodex.SessionId != "codex-native-1" || rawCodex.ToolName != "shell")
throw new InvalidOperationException("Codex native hook payload normalization failed.");
await ExpectAck(client, new PipeMessage(PipeMessageType.Event, "message-1", Event: testEvent));
await ExpectAck(client, new PipeMessage(PipeMessageType.Heartbeat, "heartbeat-1"));

if (!machine.TryGet("session-1", out var snapshot) || snapshot?.State != SessionState.Running)
throw new InvalidOperationException("Event did not reach the session state machine.");
Console.WriteLine("SELF-TEST PASS: handshake, event acknowledgement, heartbeat and state update verified.");
await stop.CancelAsync();
await serverTask;
}
else
{
Console.Error.WriteLine("Usage: codeisland-bridge [serve | send <event.json> | send --stdin [--source codex] [--event SessionStart] [--user-sid SID] | self-test]");
return 2;
}

return 0;

static PipeServer CreateServer(SessionStateMachine machine) => new((message, _) =>
{
var response = message.Type switch
{
PipeMessageType.Hello or PipeMessageType.Heartbeat =>
new PipeMessage(PipeMessageType.Ack, Guid.NewGuid().ToString("N"), AckFor: message.MessageId),
PipeMessageType.Event when message.Event is not null =>
new PipeMessage(PipeMessageType.Ack, Guid.NewGuid().ToString("N"), AckFor: message.MessageId),
_ => new PipeMessage(PipeMessageType.Error, Guid.NewGuid().ToString("N"), Error: "Unsupported message.")
};
if (message.Type == PipeMessageType.Event && message.Event is not null) machine.Apply(message.Event);
return ValueTask.FromResult<PipeMessage?>(response);
});

static async Task ExpectAck(PipeClient client, PipeMessage message)
{
var response = await client.SendAsync(message, TimeSpan.FromSeconds(2));
if (response.Type != PipeMessageType.Ack || response.AckFor != message.MessageId)
throw new InvalidOperationException($"Expected ACK for {message.MessageId}.");
}

static AgentEvent ParseEvent(string json, string? source = null, string? eventName = null)
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("eventId", out _)
&& root.TryGetProperty("sessionId", out _)
&& root.TryGetProperty("type", out _))
{
return JsonSerializer.Deserialize<AgentEvent>(json, CreateEventJsonOptions())
?? throw new InvalidOperationException("Input event JSON is empty.");
}
return RawAgentEventNormalizer.Normalize(json, source, eventName);
}

static string? GetOption(string[] values, string name)
{
var index = Array.FindIndex(values, value => value.Equals(name, StringComparison.OrdinalIgnoreCase));
return index >= 0 && index + 1 < values.Length ? values[index + 1] : null;
}

static JsonSerializerOptions CreateEventJsonOptions() => new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
};

static string HookResponse(PipeMessage response, AgentEvent agentEvent, string? source)
{
if (agentEvent.Type != AgentEventType.PermissionRequest
|| !string.Equals(source, "codex", StringComparison.OrdinalIgnoreCase)
|| response.Type != PipeMessageType.ActionResponse)
return PipeJson.Serialize(response);

var behavior = response.Action switch
{
UserAction.Approve => "allow",
UserAction.AlwaysAllow => "always",
_ => "deny"
};
return JsonSerializer.Serialize(new
{
hookSpecificOutput = new
{
hookEventName = "PermissionRequest",
decision = new { behavior }
}
});
}
30 changes: 30 additions & 0 deletions windows/CodeIsland.Core.Tests/CodeIsland.Core.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CodeIsland.Core\CodeIsland.Core.csproj" />
<ProjectReference Include="..\CodeIsland.Protocol\CodeIsland.Protocol.csproj" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions windows/CodeIsland.Core.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Xunit;
Loading