diff --git a/windows/.github/workflows/windows-release.yml b/windows/.github/workflows/windows-release.yml new file mode 100644 index 00000000..4129c0b8 --- /dev/null +++ b/windows/.github/workflows/windows-release.yml @@ -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 diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 00000000..311fcd9f --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,9 @@ +.dotnet/ +**/bin/ +**/obj/ +TestResults/ +*.user +*.suo +.vs/ +artifacts/ +.codegraph/ diff --git a/windows/CodeIsland-Windows-Complete-Development-Plan.md b/windows/CodeIsland-Windows-Complete-Development-Plan.md new file mode 100644 index 00000000..b2f1eff9 --- /dev/null +++ b/windows/CodeIsland-Windows-Complete-Development-Plan.md @@ -0,0 +1,369 @@ +# CodeIsland Windows 完整复刻开发计划 + +## 1. 项目目标与边界 + +### 1.1 目标 + +在 Windows 10/11 上复刻 CodeIsland 的核心体验: + +- 监控 Claude Code、Codex、Gemini CLI、Cursor、Copilot、Trae、Qoder、Factory、CodeBuddy、OpenCode、Kimi、Cline、Pi 等 AI 编程工具。 +- 实时显示会话、工具调用、等待审批、提问、完成、失败等状态。 +- 提供顶部悬浮岛面板、系统托盘、全局快捷键、声音通知和多显示器支持。 +- 支持直接审批/拒绝权限、回答问题、跳转到对应终端或 IDE。 +- 自动安装、检查、修复和卸载 AI 工具 Hook。 +- 支持 ESP32 Buddy 硬件;移动端同步作为后续兼容功能。 + +### 1.2 复刻原则 + +- 复用协议、事件模型、Hook 语义、资源和交互流程,重写 macOS 专属桌面层。 +- Windows 主版本采用 C#/.NET 8 + WPF;不尝试将 AppKit/SwiftUI 直接移植到 Windows。 +- Unix Domain Socket 改用 Named Pipe,保留 localhost WebSocket 作为调试和远程模式。 +- 对无法稳定获取的信息提供能力检测、可配置降级和明确的用户提示。 + +### 1.3 不纳入首个正式版本的内容 + +- macOS 刘海物理区域的精确融合。 +- 直接替代 Apple Watch/ActivityKit/Live Activities;Windows 版本先提供局域网同步 API。 +- 所有终端软件的标签页级精准定位;先实现窗口级跳转,再逐个适配。 + +## 2. 交付物与质量目标 + +### 2.1 交付物 + +1. `CodeIsland.Windows` 桌面应用。 +2. `codeisland-bridge.exe` 事件桥接程序。 +3. Hook 安装器、修复器和卸载器。 +4. Windows 安装包、卸载程序、自动更新配置。 +5. 协议文档、开发文档、用户文档和故障排查手册。 +6. 单元测试、集成测试、UI 自动化测试和发布验收报告。 + +### 2.2 质量门槛 + +- 空闲状态 CPU < 1%,内存 < 150 MB(不含外部 CLI)。 +- 事件从 Hook 到面板显示的 P95 延迟 < 300 ms。 +- 面板异常退出后,Hook 不阻塞、不丢失 CLI 主流程。 +- 断开/重启/升级后可自动恢复连接和 Hook 配置。 +- 高 DPI、浅色/深色主题、多屏、缩放 100%/125%/150% 均通过验收。 + +## 3. 总体技术方案 + +### 3.1 技术栈 + +| 层 | 技术 | +|---|---| +| 桌面应用 | C#、.NET 8、WPF | +| 架构 | MVVM、CommunityToolkit.Mvvm、依赖注入 | +| Windows 互操作 | Win32 P/Invoke、UI Automation、DWM | +| IPC | Windows Named Pipes;调试用 localhost WebSocket | +| 数据 | `System.Text.Json`、JSONL、YamlDotNet | +| 日志 | Serilog + Rolling File | +| 动画 | WPF Storyboard/Composition;必要时使用 SkiaSharp | +| 音频 | NAudio 或 Windows Media APIs | +| BLE | `Windows.Devices.Bluetooth`、GATT | +| SSH | SSH.NET | +| 更新 | Velopack 或 MSIX App Installer | +| 测试 | xUnit、FluentAssertions、Moq、WinAppDriver/Playwright(辅助) | +| CI/CD | GitHub Actions Windows runner | +| Hook 脚本 | Node.js/TypeScript、Python、PowerShell | + +### 3.2 分层结构 + +```text +CodeIsland.Windows.sln +├─ CodeIsland.Core # 跨平台模型、事件、状态机、协议 +├─ CodeIsland.Protocol # JSON schema、Named Pipe、消息编解码 +├─ CodeIsland.Hooks # 工具检测、安装、修复、卸载 +├─ CodeIsland.Bridge # codeisland-bridge.exe +├─ CodeIsland.Windows # WPF UI、托盘、快捷键、窗口管理 +├─ CodeIsland.Terminals # 终端/IDE 检测、激活和跳转 +├─ CodeIsland.Bluetooth # ESP32 BLE 协议和连接管理 +├─ CodeIsland.Updater # 更新、版本和回滚 +└─ tests # 单元、集成、UI、发布测试 +``` + +## 4. 软件开发流程与阶段计划 + +## 阶段 0:项目启动与基线确认(第 1 周) + +### 工作内容 + +- 固定 CodeIsland 上游版本,记录 commit、支持工具列表和事件类型。 +- 阅读并整理 `README`、`Package.swift`、`CodeIslandCore`、Hook 安装器、TerminalActivator、HookServer。 +- 建立功能矩阵:macOS 原功能、Windows 等价实现、降级方案、验收方法。 +- 明确目标系统:Windows 10 22H2、Windows 11 23H2 及以上,x64 优先,ARM64 后续。 +- 建立 Git 仓库、分支策略、Issue 模板、代码规范和提交规范。 + +### 产出物 + +- 《需求基线》 +- 《Windows 功能差异矩阵》 +- 架构 ADR-001(Windows 技术选型) +- 可构建的空白 `.sln` + +### 完成条件 + +- 所有首版功能有唯一编号(F-001…)。 +- 每项功能有负责人、依赖和验收标准。 +- CI 能在 Windows runner 上完成 restore/build/test 空测试。 + +## 阶段 1:协议与领域模型(第 2-3 周) + +### 工作内容 + +- 定义 `AgentKind`、`SessionState`、`ToolCall`、`PermissionRequest`、`QuestionRequest`、`SessionSnapshot`。 +- 将事件统一为:`session_start`、`session_end`、`tool_start`、`tool_end`、`permission_request`、`question`、`message`、`error`、`heartbeat`。 +- 为每种事件定义 JSON Schema、版本号、必填字段、幂等键和时间戳规范。 +- 实现 JSON 编解码、未知字段兼容、协议版本协商和错误响应。 +- 实现会话状态机、超时清理、重复事件去重、断线恢复和本地快照。 +- 为上游 Hook 样本建立 golden fixtures。 + +### 产出物 + +- 协议文档和 JSON Schema +- `CodeIsland.Core`、`CodeIsland.Protocol` +- 状态机单元测试(目标覆盖率 > 90%) + +### 完成条件 + +- 使用离线样本可重放完整会话。 +- 任意畸形 JSON 不导致主进程崩溃。 +- 状态机结果与上游样例一致。 + +## 阶段 2:Bridge 与 IPC(第 4 周) + +### 工作内容 + +- 实现 `codeisland-bridge.exe`,支持一次性发送和常驻连接两种模式。 +- 默认 Named Pipe:`\\.\pipe\codeisland-{userSid}`;权限限制为当前用户。 +- 定义连接握手、心跳、发送确认、重试和超时策略。 +- Bridge 不依赖 UI;UI 未启动时快速失败并返回成功码,避免阻塞 AI CLI。 +- 增加 localhost WebSocket 调试模式和事件录制/回放命令。 +- 记录 Windows 用户目录、临时目录和日志目录的路径策略。 + +### 完成条件 + +- 1000 条事件连续发送无丢失、无死锁。 +- UI 重启期间 Hook 可重试并恢复。 +- 普通用户权限可运行,不要求管理员权限。 + +## 阶段 3:Hook 发现、安装和修复(第 5-7 周) + +### 工作内容 + +- 为每个 AI 工具定义 `Detector`、`Installer`、`Repairer`、`Uninstaller`、`HealthCheck`。 +- 检测 npm 全局包、原生 exe、VS Code 扩展、Cursor/Trae/Qoder 配置目录和用户 PATH。 +- 将 macOS shell 路径改为 Windows PowerShell/cmd 兼容命令。 +- 支持用户级安装,避免写入 `Program Files`;配置文件修改前做备份和哈希记录。 +- Hook 写入统一 Bridge 调用模板,包含 cwd、session id、tool name、事件 payload。 +- 实现版本追踪、配置冲突检测、自动修复、卸载回滚。 +- 设置页提供逐工具状态、重新安装、查看日志和复制诊断信息。 + +### 完成条件 + +- 每个首版工具都有安装、修复、卸载和健康检查测试。 +- 配置文件损坏或字段缺失时能自动恢复。 +- 安装器重复执行幂等,不覆盖用户自定义配置。 + +## 阶段 4:桌面窗口、托盘和基础 UI(第 8-10 周) + +### 工作内容 + +- 创建 WPF 主进程和单实例互斥锁。 +- 实现透明、无边框、置顶、可点击、可拖动的顶部悬浮窗口。 +- 设计“收缩态、展开态、详情态、审批态、提问态、错误态、空闲态”。 +- 实现系统托盘菜单:打开面板、暂停通知、设置、重新安装 Hook、导出诊断、退出。 +- 使用 MVVM 将 UI 与状态机完全解耦。 +- 适配 DPI、主题、键盘焦点、屏幕边界和任务栏区域。 +- 加载像素角色、工具图标、音频资源;资源全部采用可替换文件或程序集资源。 + +### 完成条件 + +- UI 不依赖 AI 工具即可用模拟事件演示完整流程。 +- 面板不会抢占当前应用焦点,审批卡片可正常点击。 +- 多屏插拔和缩放变化后窗口位置正确。 + +## 阶段 5:事件面板和交互功能(第 11-14 周) + +### 工作内容 + +- 实现会话列表、当前会话、工具调用详情、AI 回复摘要和历史事件。 +- 实现批准、拒绝、始终允许、跳过问题、取消会话等动作。 +- 将用户响应通过 IPC 发送回对应 Hook/CLI;实现响应超时和失败提示。 +- 实现会话选择、置顶、关闭、过期清理和最大可见数量设置。 +- 实现 8-bit 启动、审批、完成、错误等声音及总开关/音量设置。 +- 实现全局快捷键:展开/收起、批准、拒绝、跳转终端;支持冲突检测和重新绑定。 +- 实现通知抑制策略:仅在对应会话处于前台且用户确实可见时抑制。 + +### 完成条件 + +- 每种状态都有视觉、声音、快捷键和响应测试。 +- 审批/问题响应可端到端回到模拟 CLI。 +- 快捷键与常见应用冲突时给出可操作提示。 + +## 阶段 6:终端和 IDE 跳转(第 15-17 周) + +### 工作内容 + +- 建立进程识别器:Windows Terminal、PowerShell、cmd、WezTerm、ConEmu、VS Code、Cursor、Trae 等。 +- 第一版实现窗口级激活:按 PID、窗口句柄、工作目录和命令行匹配。 +- 实现 VS Code/Cursor URI 跳转和项目目录打开。 +- 对 Windows Terminal 使用 `wt.exe`、窗口句柄枚举和 UI Automation;记录标签级定位能力差异。 +- 提供“复制目录/命令”“在新终端打开”作为可靠兜底。 +- 记录跳转诊断信息,允许用户选择终端类型和优先策略。 + +### 完成条件 + +- 常用终端和 IDE 的窗口级跳转成功率 > 95%。 +- 标签级定位失败时不误激活其他会话,并提供降级动作。 +- 前台窗口变化不会导致面板崩溃或死循环。 + +## 阶段 7:设置、持久化和本地化(第 18-19 周) + +### 工作内容 + +- 实现 General、Behavior、Appearance、Mascots、Sound、Hooks、Shortcuts、About 设置页。 +- 使用 `%AppData%\CodeIsland` 保存用户配置,使用 `%LocalAppData%` 保存缓存和日志。 +- 配置版本迁移、默认值、导入/导出和恢复默认设置。 +- 实现中英文资源、系统语言检测和运行时切换。 +- 保存会话标题、已选显示器、通知偏好和最近状态。 +- 提供诊断导出:版本、系统、Hook 状态、日志摘要、协议统计,不包含密钥。 + +### 完成条件 + +- 升级旧配置不会丢失设置。 +- 中英文界面无截断、重叠和硬编码文本。 +- 导出的诊断包可复现主要问题但不泄露敏感数据。 + +## 阶段 8:ESP32 Buddy 与远程能力(第 20-22 周) + +### 工作内容 + +- 复用 ESP32 服务 UUID、特征 UUID、配对握手、状态上报和按钮命令协议。 +- 实现 BLE 扫描、选择、配对、重连、超时、断开和固件错误提示。 +- 通过 `Windows.Devices.Bluetooth` 完成 GATT 读写和通知订阅。 +- 保留 SSH/远程主机事件接入,明确远端 Hook 到本机 Bridge 的认证方式。 +- 为局域网同步定义只读 Session Snapshot API;默认关闭,支持配对码或局域网密钥。 +- 移动端先提供独立 REST/WebSocket 协议,不承诺 Apple Live Activity 等价功能。 + +### 完成条件 + +- BLE 长时间运行 8 小时无内存泄漏。 +- 设备断电、超出范围、重新配对后可以恢复。 +- 远程接入默认拒绝未认证连接。 + +## 阶段 9:更新、安装与发布工程(第 23-24 周) + +### 工作内容 + +- 选择 Velopack 或 MSIX;建立稳定版、测试版和回滚通道。 +- 配置代码签名、时间戳、杀毒软件误报处理和发布证书保管流程。 +- 构建 x64 安装包;验证 ARM64 兼容性后再发布 ARM64。 +- 实现开机启动、卸载清理、升级期间进程退出和失败回滚。 +- GitHub Actions 执行 restore、build、test、打包、签名、生成 SHA256、发布 Release。 +- 编写安装、升级、降级、卸载和无网络安装测试。 + +### 完成条件 + +- 普通用户可完成安装和升级。 +- 中断升级后可启动旧版本或自动修复。 +- Release 包含安装包、校验值、变更日志和已知问题。 + +## 阶段 10:测试、稳定性和发布验收(第 25-28 周) + +### 测试层级 + +1. 单元测试:模型、状态机、协议、配置迁移、Hook 合并算法。 +2. 集成测试:Bridge、Named Pipe、Hook 到 UI 的事件链路。 +3. 工具适配测试:每个 AI 工具的安装、事件、响应、卸载。 +4. UI 自动化:展开、审批、提问、设置、快捷键、托盘和多屏。 +5. 兼容性测试:Windows 10/11、管理员/普通用户、DPI、深浅色、网络断开。 +6. 压力测试:多工具、多会话、每秒 100 条事件、长时间运行。 +7. 安全测试:Named Pipe ACL、路径注入、配置注入、未授权远程连接、日志脱敏。 +8. 发布测试:全新安装、覆盖升级、回滚、卸载、残留清理。 + +### 发布门禁 + +- 自动化测试全部通过。 +- P0/P1 缺陷为 0;P2 缺陷有明确规避方案。 +- 连续运行 72 小时无崩溃和明显内存增长。 +- 至少 5 台不同硬件、2 个 Windows 版本完成验收。 +- 完成用户文档、隐私说明、许可证和第三方依赖清单。 + +## 5. 功能编号与验收清单 + +| 编号 | 功能 | 验收要点 | +|---|---|---| +| F-001 | 单实例启动 | 重复启动只激活已有面板 | +| F-002 | 托盘 | 菜单完整,退出可清理连接 | +| F-003 | 悬浮岛 | 无边框、置顶、可点击、不遮挡全屏应用 | +| F-004 | Hook 安装 | 检测、安装、修复、卸载幂等 | +| F-005 | 实时事件 | 延迟和乱序符合协议约定 | +| F-006 | 权限审批 | 允许/拒绝结果回到原会话 | +| F-007 | 问题回答 | 文本响应正确路由 | +| F-008 | 多会话 | 独立状态、标题、超时和清理 | +| F-009 | 声音 | 事件映射、开关、音量有效 | +| F-010 | 快捷键 | 注册、冲突检测、动态修改 | +| F-011 | 多屏/DPI | 显示器变化后位置和尺寸正确 | +| F-012 | 终端跳转 | 窗口级跳转可靠,失败可降级 | +| F-013 | IDE 跳转 | 项目和窗口匹配正确 | +| F-014 | 设置 | 配置持久化、迁移、导入导出 | +| F-015 | 本地化 | 中英文无布局问题 | +| F-016 | ESP32 | 扫描、配对、重连、控制命令 | +| F-017 | 更新 | 升级、回滚、签名和校验 | +| F-018 | 诊断 | 日志、导出、敏感信息脱敏 | + +## 6. 主要风险与应对 + +| 风险 | 影响 | 应对 | +|---|---|---| +| Windows Terminal 无稳定标签 API | 高 | 先窗口级;使用 UI Automation 适配,提供新窗口/复制路径兜底 | +| 不同 AI 工具 Hook 格式变化 | 高 | 适配器版本化、golden fixtures、启动时健康检查 | +| 透明置顶窗口与全屏/游戏冲突 | 中高 | 检测独占全屏,自动隐藏并允许用户配置 | +| 杀毒软件拦截 Bridge | 中高 | 代码签名、最小权限、固定发布渠道、诊断文档 | +| BLE 驱动和权限差异 | 中 | 能力探测、重连退避、串口/局域网备用方案 | +| 远程同步泄露会话内容 | 高 | 默认关闭、只读快照、配对码、加密和日志脱敏 | +| UI 长时间运行泄漏 | 中 | 性能基线、事件限流、定时快照和 72 小时 soak test | + +## 7. 建议团队与排期 + +### 最小团队 + +- 1 名 Windows/.NET 主程:窗口、IPC、系统集成。 +- 1 名协议/Hook 主程:AI 工具适配、安装器、测试。 +- 1 名 UI/交互开发:WPF、动画、设置页、本地化。 +- 0.5 名测试/发布工程师:自动化、兼容性、打包和签名。 + +### 里程碑 + +- M1(第 4 周):协议、Bridge、事件回放可用。 +- M2(第 10 周):悬浮面板、托盘和模拟数据闭环。 +- M3(第 14 周):三种 AI 工具端到端可用。 +- M4(第 19 周):全工具 Hook、设置、本地化完成。 +- M5(第 22 周):终端/IDE 跳转和 ESP32 Beta 完成。 +- M6(第 28 周):稳定版发布。 + +## 8. 开发执行顺序 + +实际编码时严格遵循以下顺序: + +1. 先冻结事件协议和功能矩阵。 +2. 先写 Core 状态机和协议测试,再写 UI。 +3. 先完成 Bridge 和事件回放,再接入真实 AI 工具。 +4. 先支持三个高价值工具,再批量扩展适配器。 +5. 先实现窗口级跳转,再做终端标签级增强。 +6. 先完成无硬件主流程,再接入 ESP32。 +7. 所有功能进入设置页前,先具备配置、日志和测试。 +8. 每个里程碑都生成可安装构建和回归报告。 + +## 9. 最终验收标准 + +项目只有在以下条件全部满足时才视为“完成复刻”: + +- 首版支持工具均能自动安装 Hook 并接收真实事件。 +- 状态、审批、提问、完成、失败和错误流程端到端闭环。 +- 悬浮面板、托盘、快捷键、声音、多屏和设置功能完整可用。 +- 常见 Windows 终端和 IDE 至少支持窗口级可靠跳转。 +- ESP32 功能通过配对、断线、重连和长时间测试。 +- 安装包能在干净 Windows 环境中安装、升级、卸载。 +- 所有已知 macOS 差异写入用户文档,不能以“行为不一致但未说明”作为发布状态。 + diff --git a/windows/CodeIsland.Bluetooth/BuddyProtocol.cs b/windows/CodeIsland.Bluetooth/BuddyProtocol.cs new file mode 100644 index 00000000..8d22991a --- /dev/null +++ b/windows/CodeIsland.Bluetooth/BuddyProtocol.cs @@ -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 hostId) => EncodeHostFrame(PairRequestMarker, hostId); + public static byte[] EncodeUnpair(ReadOnlySpan hostId) => EncodeHostFrame(UnpairMarker, hostId); + + public static BuddyUplinkEvent? DecodeUplink(ReadOnlySpan 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 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 } diff --git a/windows/CodeIsland.Bluetooth/CodeIsland.Bluetooth.csproj b/windows/CodeIsland.Bluetooth/CodeIsland.Bluetooth.csproj new file mode 100644 index 00000000..96c6e941 --- /dev/null +++ b/windows/CodeIsland.Bluetooth/CodeIsland.Bluetooth.csproj @@ -0,0 +1,13 @@ + + + + + + + + net8.0 + enable + enable + + + diff --git a/windows/CodeIsland.Bridge/AssemblyInfo.cs b/windows/CodeIsland.Bridge/AssemblyInfo.cs new file mode 100644 index 00000000..37fefc24 --- /dev/null +++ b/windows/CodeIsland.Bridge/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("windows")] diff --git a/windows/CodeIsland.Bridge/CodeIsland.Bridge.csproj b/windows/CodeIsland.Bridge/CodeIsland.Bridge.csproj new file mode 100644 index 00000000..72d6c40a --- /dev/null +++ b/windows/CodeIsland.Bridge/CodeIsland.Bridge.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/windows/CodeIsland.Bridge/Program.cs b/windows/CodeIsland.Bridge/Program.cs new file mode 100644 index 00000000..a60e98e4 --- /dev/null +++ b/windows/CodeIsland.Bridge/Program.cs @@ -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 | 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(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(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 } + } + }); +} diff --git a/windows/CodeIsland.Core.Tests/CodeIsland.Core.Tests.csproj b/windows/CodeIsland.Core.Tests/CodeIsland.Core.Tests.csproj new file mode 100644 index 00000000..bd7d0d93 --- /dev/null +++ b/windows/CodeIsland.Core.Tests/CodeIsland.Core.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/windows/CodeIsland.Core.Tests/GlobalUsings.cs b/windows/CodeIsland.Core.Tests/GlobalUsings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/windows/CodeIsland.Core.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/windows/CodeIsland.Core.Tests/ProtocolSerializerTests.cs b/windows/CodeIsland.Core.Tests/ProtocolSerializerTests.cs new file mode 100644 index 00000000..ea60f98e --- /dev/null +++ b/windows/CodeIsland.Core.Tests/ProtocolSerializerTests.cs @@ -0,0 +1,28 @@ +using CodeIsland.Core; +using CodeIsland.Protocol; + +namespace CodeIsland.Core.Tests; + +public sealed class ProtocolSerializerTests +{ + [Fact] + public void RoundTripsEnvelope() + { + var value = new AgentEvent("event-1", "session-1", AgentKind.Claude, + AgentEventType.PermissionRequest, DateTimeOffset.UtcNow, Text: "Allow?"); + + var json = ProtocolSerializer.Serialize(ProtocolEnvelope.Create(value)); + var result = ProtocolSerializer.Deserialize(json); + + Assert.Equal(ProtocolEnvelope.CurrentVersion, result.ProtocolVersion); + Assert.Equal(value, result.Event); + Assert.Contains("permission_request", json); + } + + [Fact] + public void RejectsUnknownProtocolVersion() + { + const string json = "{\"protocolVersion\":99,\"event\":{}}"; + Assert.Throws(() => ProtocolSerializer.Deserialize(json)); + } +} diff --git a/windows/CodeIsland.Core.Tests/SessionStateMachineTests.cs b/windows/CodeIsland.Core.Tests/SessionStateMachineTests.cs new file mode 100644 index 00000000..e2e5874d --- /dev/null +++ b/windows/CodeIsland.Core.Tests/SessionStateMachineTests.cs @@ -0,0 +1,77 @@ +using CodeIsland.Core; + +namespace CodeIsland.Core.Tests; + +public sealed class SessionStateMachineTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 17, 10, 0, 0, TimeSpan.Zero); + + [Fact] + public void AppliesFullSessionLifecycle() + { + var machine = new SessionStateMachine(); + + Assert.True(machine.Apply(Event("1", AgentEventType.SessionStart))); + Assert.True(machine.Apply(Event("2", AgentEventType.ToolStart, tool: "shell"))); + Assert.True(machine.Apply(Event("3", AgentEventType.PermissionRequest, text: "Run command?"))); + Assert.True(machine.TryGet("session-1", out var waiting)); + Assert.Equal(SessionState.WaitingForPermission, waiting!.State); + Assert.Equal("3", waiting.PendingEventId); + Assert.Equal("shell", waiting.ActiveTool); + + Assert.True(machine.Apply(Event("4", AgentEventType.ToolEnd, tool: "shell"))); + Assert.True(machine.Apply(Event("5", AgentEventType.SessionEnd))); + Assert.True(machine.TryGet("session-1", out var completed)); + Assert.Equal(SessionState.Completed, completed!.State); + Assert.Null(completed.ActiveTool); + } + + [Fact] + public void DuplicateEventIsIgnored() + { + var machine = new SessionStateMachine(); + var value = Event("same", AgentEventType.SessionStart); + + Assert.True(machine.Apply(value)); + Assert.False(machine.Apply(value)); + Assert.Single(machine.Sessions); + } + + [Fact] + public void ErrorMarksSessionFailed() + { + var machine = new SessionStateMachine(); + machine.Apply(Event("1", AgentEventType.SessionStart)); + machine.Apply(Event("2", AgentEventType.Error, text: "process exited")); + + Assert.True(machine.TryGet("session-1", out var snapshot)); + Assert.Equal(SessionState.Failed, snapshot!.State); + Assert.Equal("process exited", snapshot.Error); + } + + [Fact] + public void NewActivityRecoversFailedSessionAndClearsStaleError() + { + var machine = new SessionStateMachine(); + machine.Apply(Event("1", AgentEventType.SessionStart)); + machine.Apply(Event("2", AgentEventType.Error, text: "Codex task failed")); + machine.Apply(Event("3", AgentEventType.SessionStart, text: "Codex task restarted")); + + Assert.True(machine.TryGet("session-1", out var restarted)); + Assert.Equal(SessionState.Running, restarted!.State); + Assert.Null(restarted.Error); + Assert.Equal("Codex task restarted", restarted.LastMessage); + + machine.Apply(Event("4", AgentEventType.Error, text: "interrupted again")); + machine.Apply(Event("5", AgentEventType.Message, text: "Continuing with live output")); + + Assert.True(machine.TryGet("session-1", out var continued)); + Assert.Equal(SessionState.Running, continued!.State); + Assert.Null(continued.Error); + Assert.Equal("Continuing with live output", continued.LastMessage); + } + + private static AgentEvent Event(string id, AgentEventType type, string? text = null, string? tool = null) => + new(id, "session-1", AgentKind.Codex, type, Now.AddSeconds(int.Parse(id == "same" ? "0" : id)), + @"E:\repo", "Task", text, tool); +} diff --git a/windows/CodeIsland.Core/AgentEvent.cs b/windows/CodeIsland.Core/AgentEvent.cs new file mode 100644 index 00000000..01b154e4 --- /dev/null +++ b/windows/CodeIsland.Core/AgentEvent.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace CodeIsland.Core; + +public enum AgentEventType +{ + SessionStart, + SessionEnd, + ToolStart, + ToolEnd, + PermissionRequest, + Question, + Message, + Error, + Heartbeat +} + +public sealed record AgentEvent( + string EventId, + string SessionId, + AgentKind Agent, + AgentEventType Type, + DateTimeOffset Timestamp, + string? WorkingDirectory = null, + string? Title = null, + string? Text = null, + string? ToolName = null, + JsonElement? Payload = null, + int? ProcessId = null, + string? TerminalKind = null); diff --git a/windows/CodeIsland.Core/AgentKind.cs b/windows/CodeIsland.Core/AgentKind.cs new file mode 100644 index 00000000..5f9cb4fd --- /dev/null +++ b/windows/CodeIsland.Core/AgentKind.cs @@ -0,0 +1,19 @@ +namespace CodeIsland.Core; + +public enum AgentKind +{ + Unknown, + Claude, + Codex, + Gemini, + Cursor, + Copilot, + Trae, + Qoder, + Factory, + CodeBuddy, + OpenCode, + Kimi, + Cline, + Pi +} diff --git a/windows/CodeIsland.Core/CodeIsland.Core.csproj b/windows/CodeIsland.Core/CodeIsland.Core.csproj new file mode 100644 index 00000000..fa71b7ae --- /dev/null +++ b/windows/CodeIsland.Core/CodeIsland.Core.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/windows/CodeIsland.Core/SessionSnapshot.cs b/windows/CodeIsland.Core/SessionSnapshot.cs new file mode 100644 index 00000000..f7d35fa4 --- /dev/null +++ b/windows/CodeIsland.Core/SessionSnapshot.cs @@ -0,0 +1,17 @@ +namespace CodeIsland.Core; + +public sealed record SessionSnapshot( + string SessionId, + AgentKind Agent, + SessionState State, + DateTimeOffset StartedAt, + DateTimeOffset UpdatedAt, + string? WorkingDirectory, + string? Title, + string? LastMessage, + string? ActiveTool, + string? PendingEventId, + string? Error, + int? ProcessId, + string? TerminalKind, + bool IsExecutingTool = false); diff --git a/windows/CodeIsland.Core/SessionState.cs b/windows/CodeIsland.Core/SessionState.cs new file mode 100644 index 00000000..f403a45a --- /dev/null +++ b/windows/CodeIsland.Core/SessionState.cs @@ -0,0 +1,12 @@ +namespace CodeIsland.Core; + +public enum SessionState +{ + Idle, + Running, + WaitingForPermission, + WaitingForAnswer, + Completed, + Failed, + Cancelled +} diff --git a/windows/CodeIsland.Core/SessionStateMachine.cs b/windows/CodeIsland.Core/SessionStateMachine.cs new file mode 100644 index 00000000..23af34e2 --- /dev/null +++ b/windows/CodeIsland.Core/SessionStateMachine.cs @@ -0,0 +1,130 @@ +namespace CodeIsland.Core; + +public sealed class SessionStateMachine +{ + private readonly Dictionary _sessions = new(StringComparer.Ordinal); + private readonly HashSet _processedEventIds = new(StringComparer.Ordinal); + + public IReadOnlyCollection Sessions => _sessions.Values; + + public bool Apply(AgentEvent agentEvent) + { + ArgumentNullException.ThrowIfNull(agentEvent); + Validate(agentEvent); + + if (!_processedEventIds.Add(agentEvent.EventId)) + { + return false; + } + + _sessions.TryGetValue(agentEvent.SessionId, out var current); + var startedAt = current?.StartedAt ?? agentEvent.Timestamp; + var state = ResolveState(agentEvent.Type, current?.State); + + _sessions[agentEvent.SessionId] = new SessionSnapshot( + agentEvent.SessionId, + agentEvent.Agent == AgentKind.Unknown ? current?.Agent ?? AgentKind.Unknown : agentEvent.Agent, + state, + startedAt, + agentEvent.Timestamp, + agentEvent.WorkingDirectory ?? current?.WorkingDirectory, + agentEvent.Title ?? current?.Title, + ResolveMessage(agentEvent, current), + ResolveActiveTool(agentEvent, current), + state is SessionState.WaitingForPermission or SessionState.WaitingForAnswer + ? agentEvent.EventId + : null, + ResolveError(agentEvent, current), + agentEvent.ProcessId ?? current?.ProcessId, + agentEvent.TerminalKind ?? current?.TerminalKind, + ResolveExecutingTool(agentEvent, current)); + + return true; + } + + public bool TryGet(string sessionId, out SessionSnapshot? snapshot) => + _sessions.TryGetValue(sessionId, out snapshot); + + public bool ResolvePending(string sessionId, string eventId, DateTimeOffset timestamp) + { + if (!_sessions.TryGetValue(sessionId, out var snapshot) || snapshot.PendingEventId != eventId) + return false; + _sessions[sessionId] = snapshot with + { + State = SessionState.Running, + UpdatedAt = timestamp, + PendingEventId = null + }; + return true; + } + + public int RemoveExpired(DateTimeOffset cutoff) => + _sessions.Where(pair => pair.Value.UpdatedAt < cutoff) + .Select(pair => pair.Key) + .ToArray() + .Count(key => _sessions.Remove(key)); + + public bool Remove(string sessionId) => _sessions.Remove(sessionId); + + private static SessionState ResolveState(AgentEventType type, SessionState? current) => type switch + { + AgentEventType.SessionStart => SessionState.Running, + AgentEventType.SessionEnd => SessionState.Completed, + AgentEventType.ToolStart => SessionState.Running, + AgentEventType.ToolEnd => SessionState.Running, + AgentEventType.PermissionRequest => SessionState.WaitingForPermission, + AgentEventType.Question => SessionState.WaitingForAnswer, + AgentEventType.Message => current is SessionState.Completed or SessionState.Cancelled + ? current.Value + : SessionState.Running, + AgentEventType.Error => SessionState.Failed, + AgentEventType.Heartbeat => current ?? SessionState.Running, + _ => current ?? SessionState.Idle + }; + + private static string? ResolveMessage(AgentEvent value, SessionSnapshot? current) => + value.Type == AgentEventType.ToolStart + ? value.Text ?? current?.LastMessage + : value.Type == AgentEventType.SessionStart + ? value.Text + : value.Type is AgentEventType.Message + or AgentEventType.Question or AgentEventType.PermissionRequest + || value.Type == AgentEventType.Heartbeat && !string.IsNullOrWhiteSpace(value.Text) + || value.Type == AgentEventType.ToolEnd && !string.IsNullOrWhiteSpace(value.Text) + ? value.Text ?? current?.LastMessage + : current?.LastMessage; + + private static string? ResolveError(AgentEvent value, SessionSnapshot? current) => value.Type switch + { + AgentEventType.Error => value.Text, + AgentEventType.SessionStart or AgentEventType.ToolStart or AgentEventType.ToolEnd + or AgentEventType.Message or AgentEventType.PermissionRequest or AgentEventType.Question => null, + _ => current?.Error + }; + + private static string? ResolveActiveTool(AgentEvent value, SessionSnapshot? current) => value.Type switch + { + AgentEventType.ToolStart => value.ToolName, + AgentEventType.ToolEnd => null, + AgentEventType.SessionEnd => null, + AgentEventType.Error => null, + _ => current?.ActiveTool + }; + + private static bool ResolveExecutingTool(AgentEvent value, SessionSnapshot? current) => value.Type switch + { + AgentEventType.SessionStart or AgentEventType.ToolStart or AgentEventType.ToolEnd => true, + AgentEventType.Heartbeat when value.ToolName == "background" => true, + AgentEventType.Message or AgentEventType.SessionEnd or AgentEventType.Error + or AgentEventType.PermissionRequest or AgentEventType.Question => false, + _ => current?.IsExecutingTool ?? false + }; + + private static void Validate(AgentEvent value) + { + if (string.IsNullOrWhiteSpace(value.EventId)) + throw new ArgumentException("EventId is required.", nameof(value)); + if (string.IsNullOrWhiteSpace(value.SessionId)) + throw new ArgumentException("SessionId is required.", nameof(value)); + } +} diff --git a/windows/CodeIsland.Hooks.Cli/CodeIsland.Hooks.Cli.csproj b/windows/CodeIsland.Hooks.Cli/CodeIsland.Hooks.Cli.csproj new file mode 100644 index 00000000..9f825c6d --- /dev/null +++ b/windows/CodeIsland.Hooks.Cli/CodeIsland.Hooks.Cli.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/windows/CodeIsland.Hooks.Cli/Program.cs b/windows/CodeIsland.Hooks.Cli/Program.cs new file mode 100644 index 00000000..06a99db5 --- /dev/null +++ b/windows/CodeIsland.Hooks.Cli/Program.cs @@ -0,0 +1,151 @@ +using CodeIsland.Hooks; +using System.Text.Json.Nodes; + +var command = args.FirstOrDefault()?.ToLowerInvariant() ?? "status"; +if (command == "status") +{ + foreach (var status in new ToolDetector().DetectAll()) + { + Console.WriteLine($"{status.Tool.DisplayName,-14} executable={status.ExecutablePath ?? "not found"}"); + Console.WriteLine($"{new string(' ', 15)}config={status.ConfigPath ?? "not found"} hook={status.HookInstalled} healthy={status.IsHealthy}"); + } + return 0; +} + +if (command == "self-test") +{ + var root = Path.Combine(Path.GetTempPath(), $"codeisland-hooks-{Guid.NewGuid():N}"); + var home = Path.Combine(root, "home"); + var bin = Path.Combine(root, "bin"); + var backups = Path.Combine(root, "backups"); + var bridge = Path.Combine(root, "codeisland-bridge.exe"); + Directory.CreateDirectory(home); + Directory.CreateDirectory(bin); + try + { + var tool = KnownTools.All.Single(value => value.DisplayName == "Codex"); + File.WriteAllText(Path.Combine(bin, "codex.cmd"), "@echo off"); + File.WriteAllText(bridge, "bridge"); + var configPath = Path.Combine(home, tool.ConfigPaths[0]); + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + File.WriteAllText(configPath, "{\"model\":\"gpt-5\"}"); + + var store = new HookFileStore(backups); + var manager = new HookManager(new ToolDetector(home, bin, store), store); + Require(!manager.GetStatus(tool).HookInstalled, "Hook must initially be absent."); + Require(manager.Install(tool, bridge).IsHealthy, "Install must produce a healthy status."); + var registration = store.Read(configPath, tool.HookMarker); + Require(registration?.Command.Contains(Path.GetFullPath(bridge), StringComparison.Ordinal) == true, + "Registration must invoke the selected Bridge executable."); + Require(registration?.Events.SequenceEqual(tool.Events) == true, "Registration must contain all tool events."); + Require(tool.Events.SequenceEqual(["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", + "PostToolUse", "PermissionRequest", "Stop"]), + "Codex must install the complete upstream-compatible hook event set."); + Require(registration?.ProtocolVersion == HookRegistration.CurrentProtocolVersion, + "Registration must contain the current protocol version."); + var codexRoot = JsonNode.Parse(File.ReadAllText(configPath))!.AsObject(); + var codexEntry = codexRoot["hooks"]?[tool.Events[0]]?[0]?.AsObject(); + Require(codexEntry is not null && !codexEntry.ContainsKey("matcher"), + "Codex native hooks must use the matcher-free event-map format."); + Require(codexEntry?["hooks"]?[0]?["command"]?.GetValue().Contains(tool.HookMarker) == true, + "Codex native hook command must contain its registration id."); + var codexCommand = codexEntry?["hooks"]?[0]?["command"]?.GetValue() ?? string.Empty; + Require(codexCommand.Contains("--source codex", StringComparison.Ordinal) + && codexCommand.Contains($"--event {tool.Events[0]}", StringComparison.Ordinal), + "Codex native hook command must provide source and event tags."); + var permissionEntry = codexRoot["hooks"]?["PermissionRequest"]?[0]?.AsObject(); + Require(permissionEntry?["hooks"]?[0]?["timeout"]?.GetValue() == 86400, + "Codex hook timeout must allow a user decision to remain pending."); + Require(manager.Install(tool, bridge).IsHealthy, "Repeated install must be idempotent."); + Require(Directory.GetFiles(backups).Length == 1, "Idempotent install must create one backup only."); + Require(!manager.Uninstall(tool).HookInstalled, "Uninstall must remove the marker."); + Require(File.ReadAllText(configPath).Contains("gpt-5", StringComparison.Ordinal), "User configuration must be preserved."); + Require(!File.ReadAllText(configPath).Contains("codeIsland", StringComparison.Ordinal), "Uninstall must remove only CodeIsland registration."); + + var claude = KnownTools.All.Single(value => value.DisplayName == "Claude Code"); + File.WriteAllText(Path.Combine(bin, "claude.cmd"), "@echo off"); + var claudeConfig = Path.Combine(home, claude.ConfigPaths[0]); + Directory.CreateDirectory(Path.GetDirectoryName(claudeConfig)!); + File.WriteAllText(claudeConfig, "{\"theme\":\"dark\"}"); + Require(manager.Install(claude, bridge).IsHealthy, "Claude install must be healthy."); + var claudeRoot = JsonNode.Parse(File.ReadAllText(claudeConfig))!.AsObject(); + var claudeEntry = claudeRoot["hooks"]?[claude.Events[0]]?[0]?.AsObject(); + Require(claudeEntry?["matcher"]?.GetValue() == "*", + "Claude native hooks must contain a matcher."); + Require(claudeEntry?["hooks"]?[0]?["type"]?.GetValue() == "command", + "Claude native hooks must contain a command hook."); + Require(!manager.Uninstall(claude).HookInstalled, "Claude uninstall must remove registration."); + + var gemini = KnownTools.All.Single(value => value.DisplayName == "Gemini CLI"); + File.WriteAllText(Path.Combine(bin, "gemini.cmd"), "@echo off"); + var geminiConfig = Path.Combine(home, gemini.ConfigPaths[0]); + Directory.CreateDirectory(Path.GetDirectoryName(geminiConfig)!); + File.WriteAllText(geminiConfig, "{\"security\":{\"auth\":\"oauth\"}}"); + Require(manager.Install(gemini, bridge).IsHealthy, "Gemini install must be healthy."); + var geminiRoot = JsonNode.Parse(File.ReadAllText(geminiConfig))!.AsObject(); + var geminiEntry = geminiRoot["hooks"]?[gemini.Events[0]]?[0]?.AsObject(); + Require(geminiEntry is not null && !geminiEntry.ContainsKey("matcher"), + "Gemini native hooks must use the matcher-free event-map format."); + Require(geminiEntry?["hooks"]?[0]?["timeout"]?.GetValue() == 10000, + "Gemini native hook timeout must be expressed in milliseconds."); + Require(File.ReadAllText(geminiConfig).Contains("oauth", StringComparison.Ordinal), + "Gemini user settings must be preserved."); + Require(!manager.Uninstall(gemini).HookInstalled, "Gemini uninstall must remove registration."); + + var cursor = KnownTools.All.Single(value => value.DisplayName == "Cursor"); + File.WriteAllText(Path.Combine(bin, "cursor.cmd"), "@echo off"); + var cursorConfig = Path.Combine(home, cursor.ConfigPaths[0]); + Directory.CreateDirectory(Path.GetDirectoryName(cursorConfig)!); + File.WriteAllText(cursorConfig, "{\"userSetting\":true}"); + Require(manager.Install(cursor, bridge).IsHealthy, "Cursor install must be healthy."); + var cursorRoot = JsonNode.Parse(File.ReadAllText(cursorConfig))!.AsObject(); + Require(cursorRoot["version"]?.GetValue() == 1, "Cursor hooks must declare format version 1."); + Require(cursorRoot["hooks"]?[cursor.Events[0]]?[0]?["matcher"]?.GetValue() == "*", + "Cursor event entries must include a matcher."); + Require(!manager.Uninstall(cursor).HookInstalled, "Cursor uninstall must remove registration."); + + var factory = KnownTools.All.Single(value => value.DisplayName == "Factory Droid"); + File.WriteAllText(Path.Combine(bin, "droid.cmd"), "@echo off"); + var factoryConfig = Path.Combine(home, factory.ConfigPaths[0]); + Directory.CreateDirectory(Path.GetDirectoryName(factoryConfig)!); + File.WriteAllText(factoryConfig, "{}"); + Require(manager.Install(factory, bridge).IsHealthy, "Factory Droid install must be healthy."); + Require(File.ReadAllText(factoryConfig).Contains("--source droid", StringComparison.Ordinal), + "Factory hooks must use the droid source tag."); + Require(!manager.Uninstall(factory).HookInstalled, "Factory uninstall must remove registration."); + + foreach (var additionalToolName in new[] { "Qoder", "CodeBuddy", "GitHub Copilot CLI" }) + { + var additionalTool = KnownTools.All.Single(value => value.DisplayName == additionalToolName); + var executableName = additionalTool.ExecutableNames.First(name => name.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)); + File.WriteAllText(Path.Combine(bin, executableName), "@echo off"); + var additionalConfig = Path.Combine(home, additionalTool.ConfigPaths[0]); + Directory.CreateDirectory(Path.GetDirectoryName(additionalConfig)!); + File.WriteAllText(additionalConfig, "{\"preserved\":true}"); + + Require(manager.Install(additionalTool, bridge).IsHealthy, + $"{additionalToolName} install must be healthy."); + var installedText = File.ReadAllText(additionalConfig); + Require(installedText.Contains($"--source {additionalTool.Agent.ToString().ToLowerInvariant()}", StringComparison.Ordinal), + $"{additionalToolName} hooks must include the expected source tag."); + Require(manager.Uninstall(additionalTool).HookInstalled == false, + $"{additionalToolName} uninstall must remove registration."); + Require(File.ReadAllText(additionalConfig).Contains("preserved", StringComparison.Ordinal), + $"{additionalToolName} uninstall must preserve user configuration."); + } + Console.WriteLine("SELF-TEST PASS: detect, backup, install, idempotency, health and uninstall verified."); + return 0; + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, true); + } +} + +Console.Error.WriteLine("Usage: codeisland-hooks [status | self-test]"); +return 2; + +static void Require(bool condition, string message) +{ + if (!condition) throw new InvalidOperationException(message); +} diff --git a/windows/CodeIsland.Hooks/CodeIsland.Hooks.csproj b/windows/CodeIsland.Hooks/CodeIsland.Hooks.csproj new file mode 100644 index 00000000..96c6e941 --- /dev/null +++ b/windows/CodeIsland.Hooks/CodeIsland.Hooks.csproj @@ -0,0 +1,13 @@ + + + + + + + + net8.0 + enable + enable + + + diff --git a/windows/CodeIsland.Hooks/HookFileStore.cs b/windows/CodeIsland.Hooks/HookFileStore.cs new file mode 100644 index 00000000..c0dace60 --- /dev/null +++ b/windows/CodeIsland.Hooks/HookFileStore.cs @@ -0,0 +1,73 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CodeIsland.Hooks; + +public sealed class HookFileStore +{ + private readonly string _backupDirectory; + + public HookFileStore(string? backupDirectory = null) + { + _backupDirectory = backupDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodeIsland", "hook-backups"); + } + + public string Install(string configPath, HookRegistration registration) + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + var original = File.Exists(configPath) ? File.ReadAllText(configPath) : "{}"; + var root = JsonNode.Parse(original)?.AsObject() + ?? throw new JsonException($"Configuration root in '{configPath}' must be a JSON object."); + var hooks = GetHooks(root, create: true)!; + var desired = JsonSerializer.SerializeToNode(registration); + if (JsonNode.DeepEquals(hooks[registration.Id], desired)) return string.Empty; + + var backup = Path.Combine(_backupDirectory, $"{Path.GetFileName(configPath)}.{DateTime.UtcNow:yyyyMMddHHmmssfff}.bak"); + Directory.CreateDirectory(_backupDirectory); + File.WriteAllText(backup, original, new UTF8Encoding(false)); + hooks[registration.Id] = desired; + File.WriteAllText(configPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), new UTF8Encoding(false)); + return backup; + } + + public bool Remove(string configPath, string registrationId) + { + if (!File.Exists(configPath)) return false; + var root = JsonNode.Parse(File.ReadAllText(configPath))?.AsObject() + ?? throw new JsonException($"Configuration root in '{configPath}' must be a JSON object."); + var hooks = GetHooks(root, create: false); + if (hooks is null || !hooks.Remove(registrationId)) return false; + if (hooks.Count == 0 && root["codeIsland"] is JsonObject codeIsland) + { + codeIsland.Remove("hooks"); + if (codeIsland.Count == 0) root.Remove("codeIsland"); + } + File.WriteAllText(configPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), new UTF8Encoding(false)); + return true; + } + + public HookRegistration? Read(string configPath, string registrationId) + { + if (!File.Exists(configPath)) return null; + var root = JsonNode.Parse(File.ReadAllText(configPath))?.AsObject() + ?? throw new JsonException($"Configuration root in '{configPath}' must be a JSON object."); + return GetHooks(root, create: false)?[registrationId]?.Deserialize(); + } + + private static JsonObject? GetHooks(JsonObject root, bool create) + { + if (root["codeIsland"] is not JsonObject codeIsland) + { + if (!create) return null; + codeIsland = []; + root["codeIsland"] = codeIsland; + } + if (codeIsland["hooks"] is JsonObject hooks) return hooks; + if (!create) return null; + hooks = []; + codeIsland["hooks"] = hooks; + return hooks; + } +} diff --git a/windows/CodeIsland.Hooks/HookManager.cs b/windows/CodeIsland.Hooks/HookManager.cs new file mode 100644 index 00000000..b02451b4 --- /dev/null +++ b/windows/CodeIsland.Hooks/HookManager.cs @@ -0,0 +1,46 @@ +namespace CodeIsland.Hooks; + +public interface IHookManager +{ + ToolInstallation GetStatus(HookTool tool); + ToolInstallation Install(HookTool tool, string bridgePath); + ToolInstallation Repair(HookTool tool, string bridgePath); + ToolInstallation Uninstall(HookTool tool); +} + +public sealed class HookManager : IHookManager +{ + private readonly ToolDetector _detector; + private readonly HookFileStore _store; + private readonly NativeHookInstaller _native; + + public HookManager(ToolDetector detector, HookFileStore store) + { + _detector = detector; + _store = store; + _native = new NativeHookInstaller(store); + } + + public ToolInstallation GetStatus(HookTool tool) => _detector.Detect(tool); + + public ToolInstallation Install(HookTool tool, string bridgePath) + { + var status = _detector.Detect(tool); + if (status.ExecutablePath is null) + throw new InvalidOperationException($"{tool.DisplayName} executable was not found."); + if (status.ConfigPath is null) + throw new InvalidOperationException($"No supported configuration path for {tool.DisplayName}."); + if (!File.Exists(bridgePath)) throw new FileNotFoundException("CodeIsland Bridge was not found.", bridgePath); + _native.Install(status.ConfigPath, tool, HookRegistration.Create(tool, bridgePath)); + return _detector.Detect(tool); + } + + public ToolInstallation Repair(HookTool tool, string bridgePath) => Install(tool, bridgePath); + + public ToolInstallation Uninstall(HookTool tool) + { + var status = _detector.Detect(tool); + if (status.ConfigPath is not null) _native.Uninstall(status.ConfigPath, tool, tool.HookMarker); + return _detector.Detect(tool); + } +} diff --git a/windows/CodeIsland.Hooks/HookRegistration.cs b/windows/CodeIsland.Hooks/HookRegistration.cs new file mode 100644 index 00000000..51c83a0c --- /dev/null +++ b/windows/CodeIsland.Hooks/HookRegistration.cs @@ -0,0 +1,19 @@ +namespace CodeIsland.Hooks; + +public sealed record HookRegistration( + string Id, + string Command, + IReadOnlyList Events, + int ProtocolVersion, + string InstallerVersion) +{ + public const int CurrentProtocolVersion = 1; + public const string CurrentInstallerVersion = "0.2.1"; + + public static HookRegistration Create(HookTool tool, string bridgePath) => new( + tool.HookMarker, + $"\"{Path.GetFullPath(bridgePath)}\" send --stdin --registration {tool.HookMarker}", + tool.Events, + CurrentProtocolVersion, + CurrentInstallerVersion); +} diff --git a/windows/CodeIsland.Hooks/HookTool.cs b/windows/CodeIsland.Hooks/HookTool.cs new file mode 100644 index 00000000..3eea8a49 --- /dev/null +++ b/windows/CodeIsland.Hooks/HookTool.cs @@ -0,0 +1,59 @@ +using CodeIsland.Core; + +namespace CodeIsland.Hooks; + +public sealed record HookTool( + AgentKind Agent, + string DisplayName, + IReadOnlyList ExecutableNames, + IReadOnlyList ConfigPaths, + string HookMarker, + IReadOnlyList Events, + HookConfigurationFormat Format, + int CommandTimeout, + string? SourceTag = null); + +public enum HookConfigurationFormat { Claude, EventMap, Cursor } + +public sealed record ToolInstallation( + HookTool Tool, + string? ExecutablePath, + string? ConfigPath, + bool HookInstalled, + bool IsHealthy, + string? Problem); + +public static class KnownTools +{ + public static IReadOnlyList All { get; } = + [ + new(AgentKind.Claude, "Claude Code", ["claude.exe", "claude.cmd", "claude"], + [@".claude\settings.json", @".claude.json"], "codeisland-claude", + ["SessionStart", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"], HookConfigurationFormat.Claude, 5), + new(AgentKind.Codex, "Codex", ["codex.exe", "codex.cmd", "codex"], + [@".codex\hooks.json", @".codex\config.json"], "codeisland-codex", + ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"], + HookConfigurationFormat.EventMap, 5), + new(AgentKind.Gemini, "Gemini CLI", ["gemini.exe", "gemini.cmd", "gemini"], + [@".gemini\settings.json", @".gemini\hooks.json"], "codeisland-gemini", + ["SessionStart", "BeforeTool", "AfterTool", "Notification", "SessionEnd"], HookConfigurationFormat.EventMap, 10000), + new(AgentKind.Cursor, "Cursor", ["Cursor.exe", "cursor.exe", "cursor.cmd"], + [@".cursor\hooks.json"], "codeisland-cursor", + ["sessionStart", "preToolUse", "postToolUse", "sessionEnd"], HookConfigurationFormat.Cursor, 5), + new(AgentKind.Qoder, "Qoder", ["Qoder.exe", "qoder.exe", "qoder.cmd"], + [@".qoder\settings.json"], "codeisland-qoder", + ["SessionStart", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"], HookConfigurationFormat.Claude, 5), + new(AgentKind.Factory, "Factory Droid", ["droid.exe", "droid.cmd", "droid"], + [@".factory\settings.json"], "codeisland-droid", + ["SessionStart", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"], HookConfigurationFormat.Claude, 5, "droid"), + new(AgentKind.CodeBuddy, "CodeBuddy", ["CodeBuddy.exe", "codebuddy.exe", "codebuddy.cmd"], + [@".codebuddy\settings.json"], "codeisland-codebuddy", + ["SessionStart", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"], HookConfigurationFormat.Claude, 5), + new(AgentKind.Copilot, "GitHub Copilot CLI", ["copilot.exe", "copilot.cmd", "copilot"], + [@".copilot\hooks\codeisland.json"], "codeisland-copilot", + ["sessionStart", "preToolUse", "postToolUse", "sessionEnd"], HookConfigurationFormat.EventMap, 5) + ]; + + public static int TimeoutFor(HookTool tool, string eventName) => + tool.Agent == AgentKind.Codex && eventName == "PermissionRequest" ? 86400 : tool.CommandTimeout; +} diff --git a/windows/CodeIsland.Hooks/NativeHookInstaller.cs b/windows/CodeIsland.Hooks/NativeHookInstaller.cs new file mode 100644 index 00000000..fe9532ab --- /dev/null +++ b/windows/CodeIsland.Hooks/NativeHookInstaller.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CodeIsland.Hooks; + +public sealed class NativeHookInstaller +{ + private readonly HookFileStore _store; + public NativeHookInstaller(HookFileStore store) => _store = store; + + public string Install(string configPath, HookTool tool, HookRegistration registration) + { + var backup = _store.Install(configPath, registration); + var root = ParseRoot(configPath); + if (tool.Format == HookConfigurationFormat.Cursor) root["version"] = 1; + var hooks = root["hooks"] as JsonObject ?? new JsonObject(); + root["hooks"] = hooks; + foreach (var eventName in tool.Events) + { + var entries = hooks[eventName] as JsonArray ?? new JsonArray(); + hooks[eventName] = entries; + RemoveCodeIslandEntries(entries, registration.Id); + var source = tool.SourceTag ?? tool.Agent.ToString().ToLowerInvariant(); + var commandHook = new JsonObject + { + ["type"] = "command", + ["command"] = $"{registration.Command} --source {source} --event {eventName}", + ["timeout"] = KnownTools.TimeoutFor(tool, eventName) + }; + entries.Add(tool.Format is HookConfigurationFormat.Claude or HookConfigurationFormat.Cursor + ? new JsonObject { ["matcher"] = "*", ["hooks"] = new JsonArray(commandHook) } + : new JsonObject { ["hooks"] = new JsonArray(commandHook) }); + } + Save(configPath, root); + return backup; + } + + public bool Uninstall(string configPath, HookTool tool, string registrationId) + { + if (!File.Exists(configPath)) return false; + var root = ParseRoot(configPath); + var changed = false; + if (root["hooks"] is JsonObject hooks) + { + foreach (var eventName in tool.Events) + { + if (hooks[eventName] is not JsonArray entries) continue; + var before = entries.Count; + RemoveCodeIslandEntries(entries, registrationId); + changed |= before != entries.Count; + if (entries.Count == 0) hooks.Remove(eventName); + } + if (hooks.Count == 0) root.Remove("hooks"); + } + if (changed) Save(configPath, root); + return _store.Remove(configPath, registrationId) || changed; + } + + private static void RemoveCodeIslandEntries(JsonArray entries, string registrationId) + { + for (var i = entries.Count - 1; i >= 0; i--) + { + if ((entries[i]?.ToJsonString() ?? string.Empty).Contains(registrationId, StringComparison.Ordinal)) + entries.RemoveAt(i); + } + } + + private static JsonObject ParseRoot(string path) => JsonNode.Parse(File.ReadAllText(path))?.AsObject() + ?? throw new JsonException($"Configuration root in '{path}' must be a JSON object."); + + private static void Save(string path, JsonObject root) => + File.WriteAllText(path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); +} diff --git a/windows/CodeIsland.Hooks/ToolDetector.cs b/windows/CodeIsland.Hooks/ToolDetector.cs new file mode 100644 index 00000000..28e5b20f --- /dev/null +++ b/windows/CodeIsland.Hooks/ToolDetector.cs @@ -0,0 +1,54 @@ +namespace CodeIsland.Hooks; + +public sealed class ToolDetector +{ + private readonly string _userHome; + private readonly string[] _pathEntries; + private readonly HookFileStore _store; + + public ToolDetector(string? userHome = null, string? path = null, HookFileStore? store = null) + { + _userHome = userHome ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + _pathEntries = (path ?? Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + _store = store ?? new HookFileStore(); + } + + public ToolInstallation Detect(HookTool tool) + { + var executable = FindExecutable(tool.ExecutableNames); + var candidates = tool.ConfigPaths + .Select(path => Path.Combine(_userHome, path)) + .ToArray(); + var config = candidates.FirstOrDefault(File.Exists) ?? candidates.FirstOrDefault(); + var configExists = config is not null && File.Exists(config); + var registration = configExists ? _store.Read(config!, tool.HookMarker) : null; + var markerPresent = registration is not null; + var currentVersion = registration?.ProtocolVersion == HookRegistration.CurrentProtocolVersion + && registration.InstallerVersion == HookRegistration.CurrentInstallerVersion + && registration.Events.SequenceEqual(tool.Events); + var problem = executable is null + ? "Executable not found on PATH." + : !configExists + ? "No supported user configuration file found." + : !markerPresent ? "Hook registration is not installed." + : !currentVersion ? "Hook protocol version is outdated." : null; + return new ToolInstallation(tool, executable, config, markerPresent, + executable is not null && configExists && markerPresent && currentVersion, problem); + } + + public IReadOnlyList DetectAll() => KnownTools.All.Select(Detect).ToArray(); + + private string? FindExecutable(IEnumerable names) + { + foreach (var directory in _pathEntries) + { + foreach (var name in names) + { + var candidate = Path.Combine(directory, name); + if (File.Exists(candidate)) return candidate; + } + } + return null; + } +} diff --git a/windows/CodeIsland.Ipc/CodeIsland.Ipc.csproj b/windows/CodeIsland.Ipc/CodeIsland.Ipc.csproj new file mode 100644 index 00000000..a2a73115 --- /dev/null +++ b/windows/CodeIsland.Ipc/CodeIsland.Ipc.csproj @@ -0,0 +1,13 @@ + + + + + + + + net8.0 + enable + enable + + + diff --git a/windows/CodeIsland.Ipc/PipeClient.cs b/windows/CodeIsland.Ipc/PipeClient.cs new file mode 100644 index 00000000..3722eb7e --- /dev/null +++ b/windows/CodeIsland.Ipc/PipeClient.cs @@ -0,0 +1,67 @@ +using System.IO.Pipes; +using System.Text; +using System.Runtime.Versioning; + +namespace CodeIsland.Ipc; + +[SupportedOSPlatform("windows")] +public sealed class PipeClient : IAsyncDisposable +{ + private readonly string? _userSid; + private NamedPipeClientStream? _pipe; + private StreamReader? _reader; + private StreamWriter? _writer; + + public PipeClient(string? userSid = null) => _userSid = userSid; + + public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + _pipe = PipeEndpoint.CreateClient(_userSid); + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + await _pipe.ConnectAsync(timeoutSource.Token); + _reader = new StreamReader(_pipe, Encoding.UTF8, leaveOpen: true); + _writer = new StreamWriter(_pipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true }; + } + + public async Task ConnectWithRetryAsync( + int maxAttempts, + TimeSpan connectTimeout, + TimeSpan retryDelay, + CancellationToken cancellationToken = default) + { + if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts)); + Exception? lastError = null; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + await ConnectAsync(connectTimeout, cancellationToken); + return; + } + catch (Exception ex) when (ex is TimeoutException or IOException or OperationCanceledException) + { + lastError = ex; + if (cancellationToken.IsCancellationRequested) throw; + if (attempt < maxAttempts) await Task.Delay(retryDelay, cancellationToken); + } + } + throw new IOException($"Could not connect to CodeIsland pipe after {maxAttempts} attempts.", lastError); + } + + public async Task SendAsync(PipeMessage message, TimeSpan timeout, CancellationToken cancellationToken = default) + { + if (_writer is null || _reader is null) throw new InvalidOperationException("Pipe is not connected."); + await _writer.WriteLineAsync(PipeJson.Serialize(message)); + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + var line = await _reader.ReadLineAsync(timeoutSource.Token) + ?? throw new EndOfStreamException("Pipe disconnected before response."); + return PipeJson.Deserialize(line); + } + + public async ValueTask DisposeAsync() + { + if (_pipe is not null) await _pipe.DisposeAsync(); + } +} diff --git a/windows/CodeIsland.Ipc/PipeEndpoint.cs b/windows/CodeIsland.Ipc/PipeEndpoint.cs new file mode 100644 index 00000000..49585fbe --- /dev/null +++ b/windows/CodeIsland.Ipc/PipeEndpoint.cs @@ -0,0 +1,23 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Runtime.Versioning; + +namespace CodeIsland.Ipc; + +[SupportedOSPlatform("windows")] +public static class PipeEndpoint +{ + public static string Name(string? userSid = null) + { + userSid ??= WindowsIdentity.GetCurrent().User?.Value ?? "unknown"; + var normalized = new string(userSid.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray()); + return $"codeisland-{normalized}"; + } + + public static NamedPipeServerStream CreateServer(string? userSid = null) => + new(Name(userSid), PipeDirection.InOut, 1, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, 16 * 1024, 16 * 1024); + + public static NamedPipeClientStream CreateClient(string? userSid = null) => + new(".", Name(userSid), PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.WriteThrough); +} diff --git a/windows/CodeIsland.Ipc/PipeJson.cs b/windows/CodeIsland.Ipc/PipeJson.cs new file mode 100644 index 00000000..ed443882 --- /dev/null +++ b/windows/CodeIsland.Ipc/PipeJson.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeIsland.Ipc; + +public static class PipeJson +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } + }; + + public static string Serialize(PipeMessage message) => JsonSerializer.Serialize(message, Options); + + public static PipeMessage Deserialize(string json) => + JsonSerializer.Deserialize(json, Options) + ?? throw new JsonException("Pipe message is empty."); +} diff --git a/windows/CodeIsland.Ipc/PipeMessages.cs b/windows/CodeIsland.Ipc/PipeMessages.cs new file mode 100644 index 00000000..80910e61 --- /dev/null +++ b/windows/CodeIsland.Ipc/PipeMessages.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using CodeIsland.Core; + +namespace CodeIsland.Ipc; + +public enum PipeMessageType +{ + Hello, + Event, + Ack, + Heartbeat, + ActionResponse, + Error +} + +public enum UserAction { Approve, Deny, AlwaysAllow, Answer, Skip } + +public sealed record PipeMessage( + [property: JsonPropertyName("type")] PipeMessageType Type, + [property: JsonPropertyName("messageId")] string MessageId, + [property: JsonPropertyName("protocolVersion")] int ProtocolVersion = 1, + [property: JsonPropertyName("event")] AgentEvent? Event = null, + [property: JsonPropertyName("ackFor")] string? AckFor = null, + [property: JsonPropertyName("action")] UserAction? Action = null, + [property: JsonPropertyName("responseText")] string? ResponseText = null, + [property: JsonPropertyName("error")] string? Error = null); diff --git a/windows/CodeIsland.Ipc/PipeServer.cs b/windows/CodeIsland.Ipc/PipeServer.cs new file mode 100644 index 00000000..580981e9 --- /dev/null +++ b/windows/CodeIsland.Ipc/PipeServer.cs @@ -0,0 +1,68 @@ +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using System.Runtime.Versioning; + +namespace CodeIsland.Ipc; + +[SupportedOSPlatform("windows")] +public sealed class PipeServer : IAsyncDisposable +{ + private readonly string? _userSid; + private readonly Func> _handler; + private CancellationTokenSource? _stop; + + public PipeServer(Func> handler, string? userSid = null) + { + _handler = handler; + _userSid = userSid; + } + + public Task RunAsync(CancellationToken cancellationToken = default) + { + _stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + return AcceptLoopAsync(_stop.Token); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + await using var pipe = PipeEndpoint.CreateServer(_userSid); + try + { + await pipe.WaitForConnectionAsync(cancellationToken); + await HandleConnectionAsync(pipe, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (IOException) when (!cancellationToken.IsCancellationRequested) { } + } + } + + private async Task HandleConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken) + { + using var reader = new StreamReader(pipe, Encoding.UTF8, leaveOpen: true); + await using var writer = new StreamWriter(pipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true }; + while (pipe.IsConnected && !cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (line is null) break; + PipeMessage? response; + try + { + response = await _handler(PipeJson.Deserialize(line), cancellationToken); + } + catch (Exception ex) when (ex is JsonException or ArgumentException or NotSupportedException) + { + response = new PipeMessage(PipeMessageType.Error, Guid.NewGuid().ToString("N"), Error: ex.Message); + } + if (response is not null) await writer.WriteLineAsync(PipeJson.Serialize(response)); + } + } + + public async ValueTask DisposeAsync() + { + if (_stop is not null) await _stop.CancelAsync(); + _stop?.Dispose(); + } +} diff --git a/windows/CodeIsland.Protocol/CodeIsland.Protocol.csproj b/windows/CodeIsland.Protocol/CodeIsland.Protocol.csproj new file mode 100644 index 00000000..96c6e941 --- /dev/null +++ b/windows/CodeIsland.Protocol/CodeIsland.Protocol.csproj @@ -0,0 +1,13 @@ + + + + + + + + net8.0 + enable + enable + + + diff --git a/windows/CodeIsland.Protocol/CodexTranscriptParser.cs b/windows/CodeIsland.Protocol/CodexTranscriptParser.cs new file mode 100644 index 00000000..20868def --- /dev/null +++ b/windows/CodeIsland.Protocol/CodexTranscriptParser.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using CodeIsland.Core; + +namespace CodeIsland.Protocol; + +public sealed class CodexTranscriptContext +{ + public string? SessionId { get; set; } + public string? WorkingDirectory { get; set; } +} + +public static class CodexTranscriptParser +{ + public static AgentEvent? ParseLine(string line, CodexTranscriptContext context) + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + var recordType = String(root, "type"); + if (!root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object) return null; + var payloadType = String(payload, "type"); + var timestamp = DateTimeOffset.TryParse(String(root, "timestamp"), out var parsed) + ? parsed : DateTimeOffset.UtcNow; + + if (recordType == "session_meta") + { + context.SessionId = String(payload, "session_id", "id") ?? context.SessionId; + context.WorkingDirectory = String(payload, "cwd") ?? context.WorkingDirectory; + return Create(context, "session-meta", AgentEventType.SessionStart, timestamp, + title: Path.GetFileName(context.WorkingDirectory)); + } + if (string.IsNullOrWhiteSpace(context.SessionId)) return null; + + if (recordType == "event_msg") + { + return payloadType switch + { + "task_started" => Create(context, Id(payload, timestamp), AgentEventType.SessionStart, timestamp), + "agent_message" => Create(context, Id(payload, timestamp), AgentEventType.Message, timestamp, + text: Truncate(String(payload, "message"))), + "agent_reasoning" => Create(context, Id(payload, timestamp), AgentEventType.Heartbeat, timestamp, + tool: "background"), + "patch_apply_begin" => Create(context, Id(payload, timestamp), AgentEventType.ToolStart, timestamp, + tool: "apply_patch"), + "patch_apply_end" => Create(context, Id(payload, timestamp), AgentEventType.ToolEnd, timestamp, + tool: "apply_patch"), + "mcp_tool_call_begin" => Create(context, Id(payload, timestamp), AgentEventType.ToolStart, timestamp, + tool: McpTool(payload)), + "mcp_tool_call_end" => Create(context, Id(payload, timestamp), AgentEventType.ToolEnd, timestamp, + tool: McpTool(payload)), + "task_complete" or "turn_complete" => Create(context, Id(payload, timestamp), AgentEventType.SessionEnd, timestamp), + "turn_aborted" or "error" => Create(context, Id(payload, timestamp), AgentEventType.Error, timestamp, + text: String(payload, "message")), + _ => null + }; + } + if (recordType != "response_item") return null; + return payloadType switch + { + "reasoning" => Create(context, Id(payload, timestamp), AgentEventType.Heartbeat, + timestamp, text: ReasoningSummary(payload), tool: "background"), + "custom_tool_call" or "function_call" => Create(context, Id(payload, timestamp), AgentEventType.ToolStart, + timestamp, tool: ToolName(payload)), + "custom_tool_call_output" or "function_call_output" => Create(context, Id(payload, timestamp), AgentEventType.ToolEnd, + timestamp, tool: "tool"), + "message" when String(payload, "role") == "assistant" => Create(context, Id(payload, timestamp), + AgentEventType.Message, timestamp, text: AssistantText(payload)), + _ => null + }; + } + + private static AgentEvent Create(CodexTranscriptContext context, string id, AgentEventType type, + DateTimeOffset timestamp, string? text = null, string? tool = null, string? title = null) => + new(id, context.SessionId!, AgentKind.Codex, type, timestamp, context.WorkingDirectory, + title, text, tool, TerminalKind: "codex-desktop"); + + private static string Id(JsonElement payload, DateTimeOffset timestamp) => + String(payload, "call_id", "id") ?? $"transcript-{timestamp.ToUnixTimeMilliseconds()}-{Guid.NewGuid():N}"; + + private static string? AssistantText(JsonElement payload) + { + if (!payload.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) return null; + foreach (var item in content.EnumerateArray()) + { + var text = String(item, "text"); + if (!string.IsNullOrWhiteSpace(text)) return Truncate(text); + } + return null; + } + + private static string? ReasoningSummary(JsonElement payload) + { + if (!payload.TryGetProperty("summary", out var summary) || summary.ValueKind != JsonValueKind.Array) + return null; + var parts = summary.EnumerateArray() + .Select(item => String(item, "text")) + .Where(text => !string.IsNullOrWhiteSpace(text)); + return Truncate(string.Join(" / ", parts)); + } + + private static string ToolName(JsonElement payload) + { + var input = String(payload, "input", "arguments"); + var name = String(payload, "name") ?? "tool"; + if (name.Equals("request_user_input", StringComparison.OrdinalIgnoreCase)) return "approval user input"; + if (!string.IsNullOrWhiteSpace(input) + && Regex.IsMatch(input, + @"[""']sandbox_permissions[""']\s*:\s*[""']require_escalated[""']", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + return "approval terminal"; + return NestedMcpTool(payload) ?? name; + } + + private static string? Truncate(string? text) => string.IsNullOrWhiteSpace(text) + ? null + : text.Length <= 240 ? text : text[..240] + "..."; + + private static string McpTool(JsonElement payload) + { + if (!payload.TryGetProperty("invocation", out var invocation) + || invocation.ValueKind != JsonValueKind.Object) + return "plugin"; + var server = String(invocation, "server"); + var tool = String(invocation, "tool"); + return (server, tool) switch + { + ({ Length: > 0 }, { Length: > 0 }) => $"plugin {server}/{tool}", + ({ Length: > 0 }, _) => $"plugin {server}", + (_, { Length: > 0 }) => $"plugin {tool}", + _ => "plugin" + }; + } + + private static string? NestedMcpTool(JsonElement payload) + { + var input = String(payload, "input", "arguments"); + if (string.IsNullOrWhiteSpace(input)) return null; + var match = Regex.Match(input, + @"tools\.mcp__(?[A-Za-z0-9_]+)__(?[A-Za-z0-9_]+)", + RegexOptions.CultureInvariant); + return match.Success + ? $"plugin {match.Groups["server"].Value}/{match.Groups["tool"].Value}" + : null; + } + + private static string? String(JsonElement element, params string[] names) + { + foreach (var name in names) + if (element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String) + return value.GetString(); + return null; + } +} diff --git a/windows/CodeIsland.Protocol/ProtocolEnvelope.cs b/windows/CodeIsland.Protocol/ProtocolEnvelope.cs new file mode 100644 index 00000000..49612bed --- /dev/null +++ b/windows/CodeIsland.Protocol/ProtocolEnvelope.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIsland.Core; + +namespace CodeIsland.Protocol; + +public sealed record ProtocolEnvelope( + [property: JsonPropertyName("protocolVersion")] int ProtocolVersion, + [property: JsonPropertyName("event")] AgentEvent Event) +{ + public const int CurrentVersion = 1; + + public static ProtocolEnvelope Create(AgentEvent agentEvent) => new(CurrentVersion, agentEvent); +} + +public static class ProtocolSerializer +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } + }; + + public static string Serialize(ProtocolEnvelope envelope) => JsonSerializer.Serialize(envelope, Options); + + public static ProtocolEnvelope Deserialize(string json) + { + var envelope = JsonSerializer.Deserialize(json, Options) + ?? throw new JsonException("Protocol message is empty."); + if (envelope.ProtocolVersion != ProtocolEnvelope.CurrentVersion) + throw new NotSupportedException($"Protocol version {envelope.ProtocolVersion} is not supported."); + return envelope; + } +} diff --git a/windows/CodeIsland.Protocol/RawAgentEventNormalizer.cs b/windows/CodeIsland.Protocol/RawAgentEventNormalizer.cs new file mode 100644 index 00000000..dc30f9c8 --- /dev/null +++ b/windows/CodeIsland.Protocol/RawAgentEventNormalizer.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using CodeIsland.Core; + +namespace CodeIsland.Protocol; + +public static class RawAgentEventNormalizer +{ + public static AgentEvent Normalize(string json, string? sourceTag = null, string? eventTag = null) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) throw new JsonException("Hook payload must be a JSON object."); + + var eventName = FirstString(root, "hook_event_name", "hookEventName", "eventName", "event") ?? eventTag; + var sessionId = FirstString(root, "session_id", "sessionId", "conversationId", "conversation_id", "thread_id", "threadId") + ?? NestedString(root, "payload", "session_id", "sessionId", "thread_id") + ?? NestedString(root, "data", "session_id", "sessionId", "thread_id"); + var cwd = FirstString(root, "cwd", "working_directory", "workingDirectory") ?? Environment.CurrentDirectory; + if (string.IsNullOrWhiteSpace(sessionId)) + sessionId = $"{sourceTag ?? "agent"}-cwd-{StableHash(cwd):x8}"; + + var agent = ParseAgent(sourceTag ?? FirstString(root, "_source", "source", "agent")); + var type = ParseEventType(eventName, root); + var eventId = FirstString(root, "event_id", "eventId", "tool_use_id", "toolUseId", "call_id", "callId") + ?? $"{sessionId}:{eventName ?? type.ToString()}:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + var text = FirstString(root, "message", "question", "prompt", "response", "reason", "error"); + var toolName = FirstString(root, "tool_name", "toolName", "tool"); + var processId = FirstInt(root, "_ppid", "ppid", "process_id", "processId"); + var terminalKind = FirstString(root, "_term_app", "terminal", "terminalKind"); + + return new AgentEvent(eventId, sessionId, agent, type, DateTimeOffset.UtcNow, cwd, + FirstString(root, "title"), text, toolName, root.Clone(), processId, terminalKind); + } + + private static AgentEventType ParseEventType(string? value, JsonElement root) + { + var normalized = new string((value ?? string.Empty).Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + return normalized switch + { + "sessionstart" => AgentEventType.SessionStart, + "sessionend" or "stop" => AgentEventType.SessionEnd, + "pretooluse" or "toolstart" => AgentEventType.ToolStart, + "posttooluse" or "toolend" => AgentEventType.ToolEnd, + "permissionrequest" => AgentEventType.PermissionRequest, + "question" => AgentEventType.Question, + "notification" when FirstString(root, "question") is not null => AgentEventType.Question, + "error" => AgentEventType.Error, + "heartbeat" => AgentEventType.Heartbeat, + _ => AgentEventType.Message + }; + } + + private static AgentKind ParseAgent(string? value) => value?.ToLowerInvariant() switch + { + "claude" => AgentKind.Claude, "codex" => AgentKind.Codex, "gemini" => AgentKind.Gemini, + "cursor" => AgentKind.Cursor, "copilot" => AgentKind.Copilot, "trae" => AgentKind.Trae, + "qoder" => AgentKind.Qoder, "factory" or "droid" => AgentKind.Factory, + "codebuddy" => AgentKind.CodeBuddy, "opencode" => AgentKind.OpenCode, "kimi" => AgentKind.Kimi, + "cline" => AgentKind.Cline, "pi" => AgentKind.Pi, _ => AgentKind.Unknown + }; + + private static string? FirstString(JsonElement element, params string[] names) + { + foreach (var name in names) + if (element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(value.GetString())) return value.GetString(); + return null; + } + + private static string? NestedString(JsonElement root, string objectName, params string[] names) => + root.TryGetProperty(objectName, out var nested) && nested.ValueKind == JsonValueKind.Object + ? FirstString(nested, names) : null; + + private static int? FirstInt(JsonElement element, params string[] names) + { + foreach (var name in names) + if (element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number + && value.TryGetInt32(out var number)) return number; + return null; + } + + private static uint StableHash(string value) + { + uint hash = 2166136261; + foreach (var character in value) hash = (hash ^ character) * 16777619; + return hash; + } +} diff --git a/windows/CodeIsland.Windows.Smoke/CodeIsland.Windows.Smoke.csproj b/windows/CodeIsland.Windows.Smoke/CodeIsland.Windows.Smoke.csproj new file mode 100644 index 00000000..b5bf321f --- /dev/null +++ b/windows/CodeIsland.Windows.Smoke/CodeIsland.Windows.Smoke.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + Exe + net8.0-windows + enable + enable + + + diff --git a/windows/CodeIsland.Windows.Smoke/Program.cs b/windows/CodeIsland.Windows.Smoke/Program.cs new file mode 100644 index 00000000..0a9d2425 --- /dev/null +++ b/windows/CodeIsland.Windows.Smoke/Program.cs @@ -0,0 +1,509 @@ +using CodeIsland.Core; +using CodeIsland.Ipc; +using CodeIsland.Protocol; +using CodeIsland.Windows; +using CodeIsland.Bluetooth; +using System.IO.Compression; + +var appIconPath = Path.Combine(AppContext.BaseDirectory, "source", "codeisland.png"); +using (var appIcon = new System.Drawing.Bitmap(appIconPath)) +{ + Require(appIcon.Width == 256 && appIcon.Height == 256 && appIcon.GetPixel(0, 0).A == 0, + "Application pixel icon must be 256x256 with a transparent background."); +} +var appIcoPath = Path.Combine(AppContext.BaseDirectory, "source", "codeisland.ico"); +using (var appIco = new System.Drawing.Icon(appIcoPath)) + Require(appIco.Width == 256 && appIco.Height == 256, "Application ICO must contain the 256px pixel icon."); +using (var trayMenu = TrayMenuFactory.Create("en-US", () => { }, () => { }, () => { }, () => { }, () => { })) +{ + trayMenu.Items.Add("Open panel"); + Require(trayMenu.BackColor == System.Drawing.Color.FromArgb(8, 8, 9) + && trayMenu.ForeColor == System.Drawing.Color.FromArgb(232, 232, 235), + "Tray menu must use the black CodeIsland palette."); + Require(trayMenu.Items[0].Height == 34 && !trayMenu.ShowImageMargin, + "Tray menu items must use the compact pixel-theme layout."); +} +Console.WriteLine("SMOKE PASS: transparent app icon and pixel-theme tray menu verified."); + +var statusConverter = new SessionStatusTextConverter(); +Require((string)statusConverter.Convert(null, typeof(string), null!, System.Globalization.CultureInfo.InvariantCulture) + == "CODEISLAND 0", + "Collapsed idle panel must show CODEISLAND 0 instead of an active-session status."); +Console.WriteLine("SMOKE PASS: collapsed idle status text verified."); + +var codexGifPath = Path.Combine(AppContext.BaseDirectory, "source", "codex.gif"); +using (var codexGif = System.Drawing.Image.FromFile(codexGifPath)) +{ + Require(codexGif.Width == 32 && codexGif.Height == 32, + "Codex pet GIF must remain at its native 32x32 size."); + Require(codexGif.GetFrameCount(System.Drawing.Imaging.FrameDimension.Time) == 6, + "Codex pet GIF must contain six animation frames."); +} +var codexExpandedGifPath = Path.Combine(AppContext.BaseDirectory, "source", "codex-expanded.gif"); +using (var codexExpandedGif = new System.Drawing.Bitmap(codexExpandedGifPath)) +{ + Require(codexExpandedGif.Width == 32 && codexExpandedGif.Height == 32, + "Expanded Codex pet GIF must remain at its native 32x32 size."); + Require(codexExpandedGif.GetFrameCount(System.Drawing.Imaging.FrameDimension.Time) == 6, + "Expanded Codex pet GIF must contain six animation frames."); + var background = codexExpandedGif.GetPixel(0, 0); + Require(background.R == 13 && background.G == 13 && background.B == 14, + "Expanded Codex pet GIF must match the #0D0D0E session-card background."); +} +Console.WriteLine("SMOKE PASS: opaque 32x32 six-frame Codex pet GIF verified."); + +var store = new DesktopSessionStore(); +var request = new AgentEvent( + "permission-1", "session-1", AgentKind.Codex, AgentEventType.PermissionRequest, + DateTimeOffset.UtcNow, @"E:\repo", "Run tests", "Allow dotnet test?", "shell"); +using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(5)); +var pending = store.WaitForResponseAsync(request, stop.Token); + +Require(!pending.IsCompleted, "Permission request must wait for user action."); +Require(store.PendingCount == 1, "Pending request must be tracked."); +Require(store.Sessions.Single().State == SessionState.WaitingForPermission, + "Session must enter WaitingForPermission state."); +Require(store.Resolve(request.EventId, UserAction.Approve), "Approve action must resolve the request."); + +var response = await pending; +Require(response.Type == PipeMessageType.ActionResponse, "Response type must be ActionResponse."); +Require(response.Action == UserAction.Approve, "Response must preserve the user action."); +Require(response.AckFor == request.EventId, "Response must target the pending event."); +Require(store.PendingCount == 0, "Resolved request must be removed."); +Require(store.Sessions.Single().State == SessionState.Running, + "Resolved permission request must return the session to Running."); +Require(store.CurrentSession?.SessionId == request.SessionId, + "Collapsed panel must expose the current active session."); + +var denyRequest = request with { EventId = "permission-2" }; +var denyPending = store.WaitForResponseAsync(denyRequest, stop.Token); +Require(store.ResolveCurrent(UserAction.Deny), "Current pending request must be selectable for deny."); +Require((await denyPending).Action == UserAction.Deny, "Current request deny action must be returned."); + +Console.WriteLine("SMOKE PASS: permission request, pending state, approve action and response payload verified."); + +var question = request with { EventId = "question-1", Type = AgentEventType.Question, Text = "Which environment?" }; +var questionPending = store.WaitForResponseAsync(question, stop.Token); +Require(store.Sessions.Single().State == SessionState.WaitingForAnswer, "Session must enter WaitingForAnswer state."); +Require(store.Resolve(question.EventId, UserAction.Answer, "staging"), "Answer action must resolve the question."); +var answer = await questionPending; +Require(answer.Action == UserAction.Answer && answer.ResponseText == "staging", + "Answer response must preserve user text."); +Console.WriteLine("SMOKE PASS: question request and text answer payload verified."); + +var bounded = new DesktopSessionStore(maxVisibleSessions: 2, historyLimit: 2); +for (var i = 1; i <= 3; i++) +{ + bounded.Apply(new AgentEvent($"history-{i}", $"bounded-{i}", AgentKind.Claude, + AgentEventType.SessionStart, DateTimeOffset.UtcNow.AddMinutes(-i))); +} +Require(bounded.Sessions.Count == 2, "Visible sessions must respect the configured maximum."); +Require(bounded.EventHistory.Count == 2, "Event history must respect the configured maximum."); +var removed = bounded.RemoveExpired(DateTimeOffset.UtcNow.AddMinutes(-1.5)); +Require(removed == 2, "Expired session cleanup must remove sessions older than the cutoff."); +Require(bounded.RemoveSession("bounded-1"), "Visible session must support explicit removal."); +Require(bounded.SessionCount == 0, "Explicit removal must update the visible collection."); +Console.WriteLine("SMOKE PASS: state recovery, bounded history, visible limit and expiry cleanup verified."); + +var resumed = new DesktopSessionStore(); +resumed.Apply(new AgentEvent("resume-start", "resume-session", AgentKind.Codex, + AgentEventType.SessionStart, DateTimeOffset.UtcNow)); +resumed.Apply(new AgentEvent("resume-error", "resume-session", AgentKind.Codex, + AgentEventType.Error, DateTimeOffset.UtcNow, Text: "Command interrupted")); +resumed.Apply(new AgentEvent("resume-message", "resume-session", AgentKind.Codex, + AgentEventType.Message, DateTimeOffset.UtcNow, Text: "Continuing with live output")); +var resumedSession = resumed.CurrentSession ?? throw new InvalidOperationException("Resumed session is missing."); +Require(resumedSession is { State: SessionState.Running, Error: null } + && resumedSession.LastMessage == "Continuing with live output", + "A resumed Codex session must clear its stale failure and expose live output."); +Require((string)new SessionStatusTextConverter().Convert(resumedSession, typeof(string), null!, + System.Globalization.CultureInfo.InvariantCulture) == "Continuing with live output", + "Collapsed panel must show resumed live output instead of the previous failure."); +var liveWithTool = resumedSession with { ActiveTool = "shell", LastMessage = "Streaming command output" }; +Require((string)new SessionStatusTextConverter().Convert(liveWithTool, typeof(string), "collapsed", + System.Globalization.CultureInfo.InvariantCulture) == "Streaming command output", + "Collapsed panel must prioritize live output while a tool is still running."); +Console.WriteLine("SMOKE PASS: interrupted Codex session resumes with live collapsed status."); + +var prioritized = new DesktopSessionStore(maxVisibleSessions: 3); +prioritized.Apply(new AgentEvent("priority-1", "priority-first", AgentKind.Codex, + AgentEventType.SessionStart, DateTimeOffset.UtcNow)); +prioritized.Apply(new AgentEvent("priority-2", "priority-second", AgentKind.Codex, + AgentEventType.SessionStart, DateTimeOffset.UtcNow)); +Require(prioritized.MoveSessionBefore("priority-first", "priority-second"), + "Visible sessions must support drag-order prioritization."); +Require(prioritized.Sessions[0].SessionId == "priority-first" + && prioritized.CurrentSession?.SessionId == "priority-first", + "Dragged session must become the collapsed panel priority."); +Console.WriteLine("SMOKE PASS: drag ordering updates collapsed-session priority."); + +Require(NotificationSoundManager.CueFor(AgentEventType.SessionStart) == NotificationCue.Start, + "Session start must map to the start cue."); +Require(NotificationSoundManager.CueFor(AgentEventType.PermissionRequest) == NotificationCue.Approval, + "Permission request must map to the approval cue."); +Require(NotificationSoundManager.CueFor(AgentEventType.SessionEnd) == NotificationCue.Complete, + "Session end must map to the completion cue."); +Require(NotificationSoundManager.CueFor(AgentEventType.Error) == NotificationCue.Error, + "Error must map to the error cue."); +Console.WriteLine("SMOKE PASS: notification sound event mapping verified."); + +var attentionBase = new AgentEvent("attention", "attention-session", AgentKind.Codex, + AgentEventType.ToolStart, DateTimeOffset.UtcNow); +Require(!PanelAttentionPolicy.RequiresExpansion(attentionBase with { ToolName = "approval terminal" }), + "Read-only transcript approval hints without actionable buttons must not expand the panel."); +Require(PanelAttentionPolicy.RequiresExpansion(attentionBase with { Type = AgentEventType.PermissionRequest }), + "Structured permission requests must expand the panel."); +Require(PanelAttentionPolicy.RequiresExpansion(attentionBase with { Type = AgentEventType.Question }), + "User questions must expand the panel."); +Require(!PanelAttentionPolicy.RequiresExpansion(attentionBase with { ToolName = "plugin codegraph/status" }), + "Ordinary plugin calls must not expand the panel."); +Require(!PanelAttentionPolicy.RequiresExpansion(attentionBase with { Type = AgentEventType.ToolEnd, ToolName = "approval terminal" }), + "Tool completion must not reopen the panel."); +Require(!PanelAttentionPolicy.RequiresExpansion(attentionBase with { Type = AgentEventType.Error }), + "Non-interactive errors must not expand the panel."); +Console.WriteLine("SMOKE PASS: only actionable approval and question events expand the panel."); + +var settingsRoot = Path.Combine(Path.GetTempPath(), $"codeisland-settings-{Guid.NewGuid():N}"); +try +{ + var settingsStore = new SettingsStore(settingsRoot); + settingsStore.Save(new AppSettings + { + Language = "zh-CN", + SoundEnabled = false, + MaxVisibleSessions = 99, + SessionCleanupMinutes = 0 + }); + var loaded = settingsStore.Load(); + Require(loaded.Language == "zh-CN" && !loaded.SoundEnabled, "Settings must round-trip."); + Require(loaded.MaxVisibleSessions == 20 && loaded.SessionCleanupMinutes == 1, + "Numeric settings must be clamped to supported ranges."); + Require(L10n.Get("ApproveText", loaded.Language) == "允许", "Chinese resources must resolve."); + Require(L10n.Get("ApproveText", "en-US") == "Approve", "English resources must resolve."); + var exported = Path.Combine(settingsRoot, "exported.json"); + settingsStore.Export(exported, loaded); + Require(SettingsStore.Import(exported) == loaded, "Exported settings must import without data loss."); + File.WriteAllText(settingsStore.FilePath, "{broken"); + Require(settingsStore.Load() == new AppSettings(), "Malformed settings must fall back to defaults."); + var invalidImportRejected = false; + try { SettingsStore.Import(settingsStore.FilePath); } + catch (InvalidDataException) { invalidImportRejected = true; } + Require(invalidImportRejected, "Malformed imported settings must be rejected."); + Console.WriteLine("SMOKE PASS: settings round-trip, validation, fallback and localization verified."); +} +finally +{ + if (Directory.Exists(settingsRoot)) Directory.Delete(settingsRoot, true); +} + +var locatorRoot = Path.Combine(Path.GetTempPath(), $"codeisland-locator-{Guid.NewGuid():N}"); +try +{ + var appDirectory = Path.Combine(locatorRoot, "app"); + Directory.CreateDirectory(appDirectory); + var packagedBridge = Path.Combine(appDirectory, "CodeIsland.Bridge.exe"); + File.WriteAllText(packagedBridge, "bridge"); + Require(BridgeLocator.Find(appDirectory) == packagedBridge, "Packaged Bridge must be located beside the app."); + File.Delete(packagedBridge); + var developmentBridge = Path.Combine(locatorRoot, "CodeIsland.Bridge", "bin", "Debug", "net8.0", "CodeIsland.Bridge.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(developmentBridge)!); + File.WriteAllText(developmentBridge, "bridge"); + Require(BridgeLocator.Find(appDirectory, locatorRoot) == developmentBridge, + "Development Bridge must be found from the repository root."); + Console.WriteLine("SMOKE PASS: packaged and development Bridge location verified."); +} +finally +{ + if (Directory.Exists(locatorRoot)) Directory.Delete(locatorRoot, true); +} +var startupExecutable = Path.Combine(Path.GetTempPath(), "CodeIsland", "CodeIsland.Windows.exe"); +Require(StartupRegistration.BuildCommand(startupExecutable) == $"\"{Path.GetFullPath(startupExecutable)}\"", + "Startup command must quote and normalize the executable path."); +Console.WriteLine("SMOKE PASS: startup command generation verified without registry mutation."); +Require(HotKeyBinding.TryParse("ctrl+shift+i", out var parsedHotKey), "Valid shortcut must parse."); +Require(parsedHotKey.ToString() == "Ctrl+Shift+I", "Shortcut must normalize modifier and key casing."); +Require(!HotKeyBinding.TryParse("Ctrl+Shift", out _), "Shortcut without a key must be rejected."); +var normalizedSettings = new AppSettings { ToggleShortcut = "Ctrl+Shift+I", ApproveShortcut = "Ctrl+Shift+I" }.Validate(); +Require(normalizedSettings.ApproveShortcut == "Ctrl+Shift+I", "Settings validation must preserve valid shortcut syntax."); +Console.WriteLine("SMOKE PASS: shortcut parsing and normalization verified."); +Require(WindowMatcher.TitleMatchesWorkingDirectory("PowerShell - CodexStatus", @"E:\Demo\CodexStatus"), + "Window title must match the working-directory leaf name."); +Require(!WindowMatcher.TitleMatchesWorkingDirectory("PowerShell - Other", @"E:\Demo\CodexStatus"), + "Unrelated window title must not match."); +var processStore = new DesktopSessionStore(); +processStore.Apply(new AgentEvent("process-1", "process-session", AgentKind.Codex, + AgentEventType.SessionStart, DateTimeOffset.UtcNow, @"E:\Demo\CodexStatus", ProcessId: 4321, + TerminalKind: "windows-terminal")); +Require(processStore.Sessions.Single().ProcessId == 4321 + && processStore.Sessions.Single().TerminalKind == "windows-terminal", + "Process and terminal metadata must propagate to the session snapshot."); +Console.WriteLine("SMOKE PASS: window matching and process metadata propagation verified."); +var launcherRoot = Path.Combine(Path.GetTempPath(), $"codeisland-launcher-{Guid.NewGuid():N}"); +try +{ + var launcherBin = Path.Combine(launcherRoot, "bin"); + var workspace = Path.Combine(launcherRoot, "workspace"); + Directory.CreateDirectory(launcherBin); + Directory.CreateDirectory(workspace); + File.WriteAllText(Path.Combine(launcherBin, "cursor.cmd"), "@echo off"); + File.WriteAllText(Path.Combine(launcherBin, "wt.exe"), "binary"); + var launcher = new WorkspaceLauncher(); + var cursorTarget = launcher.Resolve(AgentKind.Cursor, null, workspace, launcherBin); + Require(cursorTarget?.Executable.EndsWith("cursor.cmd", StringComparison.OrdinalIgnoreCase) == true, + "Cursor sessions must prefer the Cursor launcher."); + var terminalTarget = launcher.Resolve(AgentKind.Codex, "windows-terminal", workspace, launcherBin); + Require(terminalTarget?.Executable.EndsWith("wt.exe", StringComparison.OrdinalIgnoreCase) == true + && terminalTarget.Arguments.StartsWith("-d ", StringComparison.Ordinal), + "Terminal sessions must prefer Windows Terminal with a working-directory argument."); + var codexDesktopTarget = launcher.ResolveConversation(AgentKind.Codex, "codex-desktop", + "00000000-0000-4000-8000-000000000001"); + Require(codexDesktopTarget?.Executable == + "codex://threads/00000000-0000-4000-8000-000000000001", + "Codex Desktop sessions must resolve to the exact thread deep link."); +Console.WriteLine("SMOKE PASS: Cursor, terminal and Codex Desktop thread launch resolution verified."); +} +finally +{ + if (Directory.Exists(launcherRoot)) Directory.Delete(launcherRoot, true); +} +var agentFrame = BuddyProtocol.EncodeAgent(AgentKind.Codex, SessionState.WaitingForPermission, "shell"); +Require(agentFrame.SequenceEqual(new byte[] { 1, 3, 5, (byte)'s', (byte)'h', (byte)'e', (byte)'l', (byte)'l' }), + "Buddy agent frame must match the upstream byte layout."); +var pairFrame = BuddyProtocol.EncodePairRequest(new byte[] { 1, 2, 3, 4, 5, 6 }); +Require(pairFrame.SequenceEqual(new byte[] { 0xE0, 1, 2, 3, 4, 5, 6 }), + "Buddy pair request must contain marker and six-byte host id."); +Require(BuddyProtocol.EncodeBrightness(500).SequenceEqual(new byte[] { 0xFE, 100 }), + "Buddy brightness must clamp to 100 percent."); +Require(BuddyProtocol.DecodeUplink(new byte[] { 0xF0 }) is { Kind: BuddyUplinkKind.ControlCommand, Value: 0xF0 }, + "Buddy approve opcode must decode as a control command."); +Require(BuddyProtocol.DecodeUplink(new byte[] { 0xE2 }) is { Kind: BuddyUplinkKind.PairResponse }, + "Buddy pending opcode must decode as a pair response."); +Console.WriteLine("SMOKE PASS: Buddy agent, pairing, brightness and uplink frames verified."); +var diagnosticsRoot = Path.Combine(Path.GetTempPath(), $"codeisland-diagnostics-{Guid.NewGuid():N}"); +try +{ + var blockedLogRoot = Path.Combine(diagnosticsRoot, "blocked-log-root"); + Directory.CreateDirectory(diagnosticsRoot); + File.WriteAllText(blockedLogRoot, "not a directory"); + var fallbackLogger = new AppLogger(blockedLogRoot); + fallbackLogger.Info("fallback logger must not fail application startup"); + Require(!string.Equals(fallbackLogger.LogDirectory, blockedLogRoot, StringComparison.OrdinalIgnoreCase), + "Logger must fall back when the configured log directory is unavailable."); + var logRoot = Path.Combine(diagnosticsRoot, "logs"); + var logger = new AppLogger(logRoot, maxBytes: 80); + logger.Info("Bearer abcdefghijklmnop sk-abcdefghijklmnop " + + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + logger.Info(new string('x', 100)); + logger.Info("rotation trigger"); + Require(Directory.GetFiles(logRoot, "codeisland.log*").Length >= 2, "Logger must rotate oversized files."); + var diagnosticsZip = Path.Combine(diagnosticsRoot, "diagnostics.zip"); + new DiagnosticsExporter().Export(diagnosticsZip, new AppSettings(), store, logRoot); + using var diagnosticsArchive = ZipFile.OpenRead(diagnosticsZip); + Require(diagnosticsArchive.GetEntry("diagnostics.json") is not null, "Diagnostics archive must contain a summary."); + Require(diagnosticsArchive.Entries.Any(entry => entry.FullName.StartsWith("logs/", StringComparison.Ordinal)), + "Diagnostics archive must contain logs."); + var diagnosticText = string.Join('\n', diagnosticsArchive.Entries.Select(entry => + { + using var reader = new StreamReader(entry.Open()); + return reader.ReadToEnd(); + })); + Require(!diagnosticText.Contains("abcdefghijklmnop", StringComparison.Ordinal), + "Diagnostics must redact API keys and bearer tokens."); +Console.WriteLine("SMOKE PASS: rolling logs, diagnostics ZIP and secret redaction verified."); +} +finally +{ + if (Directory.Exists(diagnosticsRoot)) Directory.Delete(diagnosticsRoot, true); +} +var displayPosition = DisplayPositioner.TopCenter(new System.Drawing.Rectangle(1920, 0, 1920, 1080), + dpiScaleX: 1.5, dpiScaleY: 1.5, widthDip: 560); +Require(Math.Abs(displayPosition.Left - 1640) < 0.01 && Math.Abs(displayPosition.Top - 14) < 0.01, + "Panel positioning must convert the selected display from pixels to DIPs."); +Require(new AppSettings { DisplayMode = "invalid" }.Validate().DisplayMode == "primary", + "Invalid display mode must fall back to primary."); +Console.WriteLine("SMOKE PASS: multi-display DPI positioning and settings validation verified."); +var workArea = new System.Windows.Rect(0, 0, 1920, 1080); +var topDock = PanelDocking.Resolve(workArea, new System.Windows.Size(780, 300), + new System.Windows.Point(570, 7)); +Require(topDock.Edges == DockEdges.Top && topDock.Top == 0, + "Panel near the top edge must snap flush to the screen."); +Require(topDock.Corners.TopLeft == 0 && topDock.Corners.TopRight == 0, + "Top-docked black panel must remain flat behind its curved shoulders."); +var bottomRightDock = PanelDocking.Resolve(workArea, new System.Windows.Size(400, 64), + new System.Windows.Point(1518, 1018)); +Require(bottomRightDock.Edges == (DockEdges.Right | DockEdges.Bottom) + && bottomRightDock.Left == 1520 && bottomRightDock.Top == 1016, + "Collapsed panel must snap exactly into a screen corner."); +Require(bottomRightDock.Corners.TopLeft > 0 && bottomRightDock.Corners.TopRight == 0 + && bottomRightDock.Corners.BottomLeft == 0 && bottomRightDock.Corners.BottomRight == 0, + "Corner-docked black panel must leave its screen-facing corners flat."); +var shoulders = DockShoulderGeometry.Create(new System.Windows.Size(780, 160), DockEdges.Top); +Require(!shoulders.First.Bounds.IsEmpty && !shoulders.Second.Bounds.IsEmpty + && shoulders.First.Bounds.Left == 0 && shoulders.Second.Bounds.Right == 780, + "Top docking must create curved shoulders at both screen connections."); +var collapsedCenter = new System.Windows.Point(340 + 320d / 2, 0 + 40d / 2); +var expandedFromCenter = PanelDocking.Place(workArea, new System.Windows.Size(780, 300), + new System.Windows.Point(collapsedCenter.X - 780d / 2, collapsedCenter.Y - 300d / 2), DockEdges.Top); +Require(Math.Abs((expandedFromCenter.Left + 780d / 2) - collapsedCenter.X) < .01, + "Top-docked expand must preserve the collapsed panel's horizontal center."); +Console.WriteLine("SMOKE PASS: four-edge snapping and seamless dock corner geometry verified."); +var monitorBounds = new System.Drawing.Rectangle(0, 0, 1920, 1080); +Require(FullscreenDetector.IsSameBounds(new System.Drawing.Rectangle(0, 0, 1920, 1080), monitorBounds), + "A window covering the monitor must be detected as full screen."); +Require(!FullscreenDetector.IsSameBounds(new System.Drawing.Rectangle(0, 0, 1920, 1040), monitorBounds), + "A window leaving taskbar space must not be detected as full screen."); +Console.WriteLine("SMOKE PASS: full-screen monitor bounds detection verified."); +var filterSession = new SessionSnapshot("filter", AgentKind.Codex, SessionState.Running, + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, null, null, null, null, null, null); +Require(SessionFilter.IsVisible(filterSession, "all"), "All filter must include active CLI sessions."); +Require(SessionFilter.IsVisible(filterSession, "active"), "Active filter must include running sessions."); +Require(!SessionFilter.IsVisible(filterSession with { State = SessionState.Completed }, "active"), + "Active filter must exclude completed sessions."); +Require(SessionFilter.IsVisible(filterSession, "cli"), "CLI filter must include known CLI agents."); +Require(!SessionFilter.IsVisible(filterSession with { Agent = AgentKind.Unknown }, "cli"), + "CLI filter must exclude unknown sources."); +Console.WriteLine("SMOKE PASS: all, active and CLI session filters verified."); +var mcpContext = new CodexTranscriptContext(); +CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"mcp-session\",\"cwd\":\"E:\\\\Demo\"}}", + mcpContext); +var mcpEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:01Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"mcp_tool_call_end\",\"call_id\":\"mcp-1\",\"invocation\":{\"server\":\"node_repl\",\"tool\":\"js\"}}}", + mcpContext); +Require(mcpEvent is { Type: AgentEventType.ToolEnd, ToolName: "plugin node_repl/js" } + && mcpEvent.Text is null, + "Codex MCP/plugin calls must be parsed with their server and tool names."); +var mcpStore = new DesktopSessionStore(); +mcpStore.Apply(mcpEvent!); +Require(mcpStore.CurrentSession?.LastMessage is null, + "Completed plugin calls must not fabricate a completion message."); +Console.WriteLine("SMOKE PASS: Codex MCP/plugin parsing does not fabricate output."); +var nestedMcpEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:02Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"custom_tool_call\",\"call_id\":\"outer-1\",\"name\":\"exec\",\"input\":\"const r = await tools.mcp__codegraph__codegraph_status({projectPath: \\\"E:\\\\\\\\Demo\\\"});\"}}", + mcpContext); +Require(nestedMcpEvent is { Type: AgentEventType.ToolStart, ToolName: "plugin codegraph/codegraph_status" }, + "Nested MCP calls must expose the plugin name before the invocation completes."); +Console.WriteLine("SMOKE PASS: nested CodeGraph invocation is identified before completion."); +var liveMessageEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:03Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"Reading the project configuration now.\"}}", + mcpContext); +Require(liveMessageEvent is { Type: AgentEventType.Message, Text: "Reading the project configuration now." }, + "Codex agent messages must be exposed as live display content."); +var reasoningEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:04Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_reasoning\",\"text\":\"hidden\"}}", + mcpContext); +Require(reasoningEvent is { Type: AgentEventType.Heartbeat, Text: null, ToolName: "background" }, + "Internal reasoning must drive background animation without creating display text."); +var reasoningSummaryEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:04Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"reasoning\",\"id\":\"reasoning-1\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"Inspecting session state\"},{\"type\":\"summary_text\",\"text\":\"Preparing the update\"}],\"encrypted_content\":\"hidden\"}}", + mcpContext); +Require(reasoningSummaryEvent is + { Type: AgentEventType.Heartbeat, Text: "Inspecting session state / Preparing the update", ToolName: "background" }, + "Public Codex reasoning summaries must be displayed without exposing encrypted reasoning content."); +var approvalEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:05Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"custom_tool_call\",\"call_id\":\"approval-1\",\"name\":\"shell_command\",\"input\":\"{\\\"sandbox_permissions\\\":\\\"require_escalated\\\",\\\"justification\\\":\\\"Allow this command?\\\"}\"}}", + mcpContext); +Require(approvalEvent is { Type: AgentEventType.ToolStart, ToolName: "approval terminal" }, + "Any terminal escalation request must be marked for automatic panel expansion."); +var ordinaryTerminalEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:05Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"custom_tool_call\",\"call_id\":\"ordinary-terminal\",\"name\":\"exec\",\"input\":\"const r = await tools.shell_command({\\\"command\\\":\\\"rg require_escalated sandbox_permissions justification .\\\",\\\"sandbox_permissions\\\":\\\"use_default\\\"});\"}}", + mcpContext); +Require(ordinaryTerminalEvent is { Type: AgentEventType.ToolStart, ToolName: "exec" }, + "Ordinary terminal commands mentioning approval terms must not trigger panel expansion."); +var inputEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:06Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"custom_tool_call\",\"call_id\":\"approval-2\",\"name\":\"request_user_input\",\"input\":\"{}\"}}", + mcpContext); +Require(inputEvent is { Type: AgentEventType.ToolStart, ToolName: "approval user input" }, + "Codex user-input requests must be marked for automatic panel expansion."); +var commandEvent = CodexTranscriptParser.ParseLine( + "{\"timestamp\":\"2026-07-20T08:00:07Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"custom_tool_call\",\"call_id\":\"command-1\",\"name\":\"exec\",\"input\":\"const r = await tools.shell_command({\\\"command\\\":\\\"dotnet test CodeIsland.Core.Tests\\\",\\\"workdir\\\":\\\"E:\\\\\\\\Demo\\\"});\"}}", + mcpContext); +Require(commandEvent is { Type: AgentEventType.ToolStart, Text: null }, + "Shell commands must not be copied into the collapsed output text."); +var syntheticStatusSession = resumedSession with { LastMessage = null, ActiveTool = "exec" }; +Require((string)statusConverter.Convert(syntheticStatusSession, typeof(string), "collapsed", + System.Globalization.CultureInfo.InvariantCulture) == "CODEISLAND 1", + "Collapsed status must never fabricate running exec_ or thinking_."); +var liveStore = new DesktopSessionStore(); +liveStore.Apply(new AgentEvent("old-message", "live-session", AgentKind.Codex, + AgentEventType.Message, DateTimeOffset.UtcNow, Text: "Previous output")); +liveStore.Apply(new AgentEvent("new-tool", "live-session", AgentKind.Codex, + AgentEventType.ToolStart, DateTimeOffset.UtcNow, ToolName: "shell_command")); +Require(liveStore.CurrentSession is + { LastMessage: "Previous output", ActiveTool: "shell_command", IsExecutingTool: true }, + "A running tool must retain the last real output and enable the collapsed wave animation."); +liveStore.Apply(new AgentEvent("new-turn", "live-session", AgentKind.Codex, + AgentEventType.SessionStart, DateTimeOffset.UtcNow)); +Require(liveStore.CurrentSession is { LastMessage: null, IsExecutingTool: true }, + "A new Codex turn must immediately clear output left by the previous turn."); +liveStore.Apply(reasoningSummaryEvent! with { SessionId = "live-session" }); +Require(liveStore.CurrentSession is + { LastMessage: "Inspecting session state / Preparing the update", IsExecutingTool: true }, + "The first public reasoning summary must replace the empty new-turn display in real time."); +liveStore.Apply(new AgentEvent("mcp-tool", "mcp-session", AgentKind.Codex, + AgentEventType.ToolStart, DateTimeOffset.UtcNow, ToolName: "plugin codegraph/status")); +liveStore.Apply(liveMessageEvent!); +Require(liveStore.Sessions.Single(value => value.SessionId == "mcp-session").IsExecutingTool == false, + "Fresh Codex output must stop the collapsed wave animation immediately."); +liveStore.Apply(reasoningEvent!); +Require(liveStore.Sessions.Single(value => value.SessionId == "mcp-session").IsExecutingTool, + "Background reasoning after output must restart the collapsed wave animation."); +liveStore.Apply(new AgentEvent("mcp-tool-end", "mcp-session", AgentKind.Codex, + AgentEventType.ToolEnd, DateTimeOffset.UtcNow, ToolName: "plugin codegraph/status")); +Require(liveStore.Sessions.Single(value => value.SessionId == "mcp-session").IsExecutingTool, + "Tool completion must keep waving while the agent continues background processing."); +liveStore.Apply(new AgentEvent("live-end", "mcp-session", AgentKind.Codex, + AgentEventType.SessionEnd, DateTimeOffset.UtcNow)); +var completedLiveSession = liveStore.Sessions.Single(value => value.SessionId == "mcp-session"); +Require((string)statusConverter.Convert(completedLiveSession, typeof(string), "collapsed", + System.Globalization.CultureInfo.InvariantCulture) == "Reading the project configuration now.", + "Collapsed completed sessions must retain the last real Codex message."); +Console.WriteLine("SMOKE PASS: live Codex output and approval detection verified."); +var transcriptRoot = Path.Combine(Path.GetTempPath(), $"codeisland-transcript-{Guid.NewGuid():N}"); +try +{ + Directory.CreateDirectory(transcriptRoot); + var transcript = Path.Combine(transcriptRoot, "rollout-test.jsonl"); + File.WriteAllLines(transcript, + [ + "{\"timestamp\":\"2026-07-17T08:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"live-session\",\"cwd\":\"E:\\\\Demo\\\\CodexStatus\"}}", + "{\"timestamp\":\"2026-07-17T08:00:01Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"task_started\"}}" + ]); + var transcriptEvents = new List(); + using var liveSignal = new ManualResetEventSlim(); + using var partialLineSignal = new ManualResetEventSlim(); + using var tailer = new CodexSessionTailer(transcriptRoot); + tailer.EventReceived += (_, agentEvent) => + { + lock (transcriptEvents) transcriptEvents.Add(agentEvent); + if (agentEvent.ToolName == "shell") liveSignal.Set(); + if (agentEvent.Text == "Partial line delivered immediately") partialLineSignal.Set(); + }; + tailer.Start(TimeSpan.FromDays(1)); + lock (transcriptEvents) + Require(transcriptEvents.Any(value => value.SessionId == "live-session"), + "Tailer startup must recover an existing active Codex session."); + File.AppendAllText(transcript, Environment.NewLine + + "{\"timestamp\":\"2026-07-17T08:00:02Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"custom_tool_call\",\"call_id\":\"call-1\",\"name\":\"shell\"}}" + + Environment.NewLine); + Require(liveSignal.Wait(TimeSpan.FromSeconds(5)), "Tailer must emit events appended after startup."); + var partialLine = "{\"timestamp\":\"2026-07-17T08:00:03Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"Partial line delivered immediately\"}}"; + var splitAt = partialLine.Length / 2; + File.AppendAllText(transcript, partialLine[..splitAt]); + Require(!partialLineSignal.Wait(TimeSpan.FromMilliseconds(250)), + "Tailer must retain an incomplete JSONL record instead of advancing past it."); + File.AppendAllText(transcript, partialLine[splitAt..] + Environment.NewLine); + Require(partialLineSignal.Wait(TimeSpan.FromSeconds(2)), + "Tailer polling must deliver a completed partial JSONL record without waiting for another event."); + Console.WriteLine("SMOKE PASS: Codex active-session recovery, partial writes and low-latency JSONL tailing verified."); +} +finally +{ + if (Directory.Exists(transcriptRoot)) Directory.Delete(transcriptRoot, true); +} +return 0; + +static void Require(bool condition, string message) +{ + if (!condition) throw new InvalidOperationException(message); +} diff --git a/windows/CodeIsland.Windows.sln b/windows/CodeIsland.Windows.sln new file mode 100644 index 00000000..8a254bfb --- /dev/null +++ b/windows/CodeIsland.Windows.sln @@ -0,0 +1,76 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Core", "CodeIsland.Core\CodeIsland.Core.csproj", "{968FD956-CC5D-4E77-A738-44630B0D359D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Protocol", "CodeIsland.Protocol\CodeIsland.Protocol.csproj", "{C10A5F3B-0686-48C0-A9B8-7AA720B89C27}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Core.Tests", "CodeIsland.Core.Tests\CodeIsland.Core.Tests.csproj", "{72F109B1-3F86-4124-9C34-8F3B1A0CB761}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Ipc", "CodeIsland.Ipc\CodeIsland.Ipc.csproj", "{41089318-27E7-40DB-AFD7-FC112E9B0460}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Bridge", "CodeIsland.Bridge\CodeIsland.Bridge.csproj", "{DF4B2734-FABF-4A7F-8516-AE9ADDD8CBCC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Hooks", "CodeIsland.Hooks\CodeIsland.Hooks.csproj", "{C2E3E592-6165-4838-B8F9-3B219A020393}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Hooks.Cli", "CodeIsland.Hooks.Cli\CodeIsland.Hooks.Cli.csproj", "{31E0206B-837A-4A31-AC9B-9AD798204D4F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Windows", "CodeIsland.Windows\CodeIsland.Windows.csproj", "{1915CF9D-9DB6-4935-B110-9301633A0CCA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Windows.Smoke", "CodeIsland.Windows.Smoke\CodeIsland.Windows.Smoke.csproj", "{88D0C12D-8CA5-4B85-915E-3A8B5762747D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeIsland.Bluetooth", "CodeIsland.Bluetooth\CodeIsland.Bluetooth.csproj", "{29D75512-475E-48B3-8936-32EDE0741B34}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {968FD956-CC5D-4E77-A738-44630B0D359D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {968FD956-CC5D-4E77-A738-44630B0D359D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {968FD956-CC5D-4E77-A738-44630B0D359D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {968FD956-CC5D-4E77-A738-44630B0D359D}.Release|Any CPU.Build.0 = Release|Any CPU + {C10A5F3B-0686-48C0-A9B8-7AA720B89C27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C10A5F3B-0686-48C0-A9B8-7AA720B89C27}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C10A5F3B-0686-48C0-A9B8-7AA720B89C27}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C10A5F3B-0686-48C0-A9B8-7AA720B89C27}.Release|Any CPU.Build.0 = Release|Any CPU + {72F109B1-3F86-4124-9C34-8F3B1A0CB761}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72F109B1-3F86-4124-9C34-8F3B1A0CB761}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72F109B1-3F86-4124-9C34-8F3B1A0CB761}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72F109B1-3F86-4124-9C34-8F3B1A0CB761}.Release|Any CPU.Build.0 = Release|Any CPU + {41089318-27E7-40DB-AFD7-FC112E9B0460}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41089318-27E7-40DB-AFD7-FC112E9B0460}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41089318-27E7-40DB-AFD7-FC112E9B0460}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41089318-27E7-40DB-AFD7-FC112E9B0460}.Release|Any CPU.Build.0 = Release|Any CPU + {DF4B2734-FABF-4A7F-8516-AE9ADDD8CBCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF4B2734-FABF-4A7F-8516-AE9ADDD8CBCC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF4B2734-FABF-4A7F-8516-AE9ADDD8CBCC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF4B2734-FABF-4A7F-8516-AE9ADDD8CBCC}.Release|Any CPU.Build.0 = Release|Any CPU + {C2E3E592-6165-4838-B8F9-3B219A020393}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2E3E592-6165-4838-B8F9-3B219A020393}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2E3E592-6165-4838-B8F9-3B219A020393}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2E3E592-6165-4838-B8F9-3B219A020393}.Release|Any CPU.Build.0 = Release|Any CPU + {31E0206B-837A-4A31-AC9B-9AD798204D4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31E0206B-837A-4A31-AC9B-9AD798204D4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31E0206B-837A-4A31-AC9B-9AD798204D4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31E0206B-837A-4A31-AC9B-9AD798204D4F}.Release|Any CPU.Build.0 = Release|Any CPU + {1915CF9D-9DB6-4935-B110-9301633A0CCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1915CF9D-9DB6-4935-B110-9301633A0CCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1915CF9D-9DB6-4935-B110-9301633A0CCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1915CF9D-9DB6-4935-B110-9301633A0CCA}.Release|Any CPU.Build.0 = Release|Any CPU + {88D0C12D-8CA5-4B85-915E-3A8B5762747D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {88D0C12D-8CA5-4B85-915E-3A8B5762747D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88D0C12D-8CA5-4B85-915E-3A8B5762747D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {88D0C12D-8CA5-4B85-915E-3A8B5762747D}.Release|Any CPU.Build.0 = Release|Any CPU + {29D75512-475E-48B3-8936-32EDE0741B34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29D75512-475E-48B3-8936-32EDE0741B34}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29D75512-475E-48B3-8936-32EDE0741B34}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29D75512-475E-48B3-8936-32EDE0741B34}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/windows/CodeIsland.Windows/AnimatedGifImage.cs b/windows/CodeIsland.Windows/AnimatedGifImage.cs new file mode 100644 index 00000000..2222adc4 --- /dev/null +++ b/windows/CodeIsland.Windows/AnimatedGifImage.cs @@ -0,0 +1,61 @@ +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media.Imaging; +using System.Windows.Threading; + +namespace CodeIsland.Windows; + +public sealed class AnimatedGifImage : System.Windows.Controls.Image +{ + public static readonly DependencyProperty SourcePathProperty = DependencyProperty.Register( + nameof(SourcePath), typeof(string), typeof(AnimatedGifImage), + new PropertyMetadata(null, OnSourcePathChanged)); + + private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(120) }; + private BitmapFrame[] _frames = []; + private int _frameIndex; + + public string? SourcePath + { + get => (string?)GetValue(SourcePathProperty); + set => SetValue(SourcePathProperty, value); + } + + public AnimatedGifImage() + { + Stretch = System.Windows.Media.Stretch.Uniform; + StretchDirection = StretchDirection.DownOnly; + SnapsToDevicePixels = true; + _timer.Tick += (_, _) => + { + if (_frames.Length == 0) return; + _frameIndex = (_frameIndex + 1) % _frames.Length; + Source = _frames[_frameIndex]; + }; + Unloaded += (_, _) => _timer.Stop(); + } + + private static void OnSourcePathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((AnimatedGifImage)d).LoadFrames(e.NewValue as string); + } + + private void LoadFrames(string? path) + { + _timer.Stop(); + _frames = []; + _frameIndex = 0; + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + Source = null; + return; + } + + using var stream = File.OpenRead(path); + var decoder = new GifBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad); + _frames = decoder.Frames.ToArray(); + Source = _frames.FirstOrDefault(); + if (_frames.Length > 1) _timer.Start(); + } +} diff --git a/windows/CodeIsland.Windows/App.xaml b/windows/CodeIsland.Windows/App.xaml new file mode 100644 index 00000000..dc3ca81b --- /dev/null +++ b/windows/CodeIsland.Windows/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/windows/CodeIsland.Windows/App.xaml.cs b/windows/CodeIsland.Windows/App.xaml.cs new file mode 100644 index 00000000..412257dc --- /dev/null +++ b/windows/CodeIsland.Windows/App.xaml.cs @@ -0,0 +1,289 @@ +using System.Configuration; +using System.Data; +using System.Windows; + +using System.Threading; +using System.Windows.Forms; +using CodeIsland.Ipc; +using CodeIsland.Core; +using System.Windows.Threading; +using System.IO; +using CodeIsland.Hooks; +using Application = System.Windows.Application; + +namespace CodeIsland.Windows; + +public partial class App : Application +{ + private const string InstanceMutexName = "CodeIsland.Windows.SingleInstance.v5"; + private const string ShowPanelEventName = "CodeIsland.Windows.ShowPanel.v5"; + private Mutex? _instanceMutex; + private EventWaitHandle? _showPanelEvent; + private RegisteredWaitHandle? _showPanelRegistration; + private NotifyIcon? _tray; + private System.Drawing.Icon? _appIcon; + private MainWindow? _window; + private PipeServer? _pipeServer; + private CancellationTokenSource? _pipeStop; + private Task? _pipeTask; + private DispatcherTimer? _cleanupTimer; + private DispatcherTimer? _fullscreenTimer; + private bool _fullscreenSuppressed; + private DateTimeOffset _manualShowUntil; + private CodexSessionTailer? _codexTailer; + private readonly NotificationSoundManager _sounds = new(); + private readonly SettingsStore _settingsStore = new(); + private SettingsWindow? _settingsWindow; + private ContextMenuStrip? _trayMenu; + private readonly AppLogger _logger = new(); + public DesktopSessionStore Sessions { get; private set; } = null!; + private AppSettings _settings = new(); + + protected override void OnStartup(StartupEventArgs e) + { + _instanceMutex = new Mutex(true, InstanceMutexName, out var created); + if (!created) + { + try + { + using var showPanel = EventWaitHandle.OpenExisting(ShowPanelEventName); + showPanel.Set(); + } + catch (Exception ex) when (ex is WaitHandleCannotBeOpenedException or UnauthorizedAccessException) { } + Shutdown(2); + return; + } + + _showPanelEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ShowPanelEventName); + _showPanelRegistration = ThreadPool.RegisterWaitForSingleObject(_showPanelEvent, (_, _) => + Dispatcher.BeginInvoke(ShowPanel), null, Timeout.Infinite, false); + + _settings = _settingsStore.Load(); + _logger.Info("Application startup."); + RepairOutdatedHooks(); + L10n.Apply(Resources, _settings.Language); + Sessions = new DesktopSessionStore(_settings.MaxVisibleSessions, _settings.EventHistoryLimit); + _sounds.Enabled = _settings.SoundEnabled; + StartPipeServer(); + Sessions.EventApplied += (_, agentEvent) => + { + _sounds.Play(agentEvent); + _logger.Info($"Event received: agent={agentEvent.Agent} type={agentEvent.Type} tool={agentEvent.ToolName ?? "-"} session={agentEvent.SessionId}"); + if (PanelAttentionPolicy.RequiresExpansion(agentEvent)) + Dispatcher.BeginInvoke(() => + { + _window?.ExpandPanel(); + ShowPanel(); + }); + }; + _codexTailer = new CodexSessionTailer(); + _codexTailer.EventReceived += (_, agentEvent) => Dispatcher.Invoke(() => Sessions.Apply(agentEvent)); + _codexTailer.Start(); + _window = new MainWindow(Sessions, _settings); + _manualShowUntil = DateTimeOffset.UtcNow.AddSeconds(30); + try + { + _window.Show(); + } + catch (Exception ex) + { + _logger.Error("Main window startup failed", ex); + Shutdown(3); + return; + } + _fullscreenTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _fullscreenTimer.Tick += (_, _) => UpdateFullscreenVisibility(); + _fullscreenTimer.Start(); + _cleanupTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) }; + _cleanupTimer.Tick += (_, _) => Sessions.RemoveExpired(DateTimeOffset.UtcNow.AddMinutes(-_settings.SessionCleanupMinutes)); + _cleanupTimer.Start(); + var iconPath = Path.Combine(AppContext.BaseDirectory, "source", "codeisland.ico"); + _appIcon = File.Exists(iconPath) + ? new System.Drawing.Icon(iconPath) + : (System.Drawing.Icon)System.Drawing.SystemIcons.Application.Clone(); + _tray = new NotifyIcon + { + Icon = _appIcon, + Visible = true, + Text = "CodeIsland" + }; + RebuildTrayMenu(); + _tray.DoubleClick += (_, _) => ShowPanel(); + if (e.Args.Contains("--settings", StringComparer.OrdinalIgnoreCase)) OpenSettings(); + base.OnStartup(e); + } + + private void RepairOutdatedHooks() + { + var bridge = BridgeLocator.FindCurrent(); + if (bridge is null) return; + try + { + var files = new HookFileStore(); + var detector = new ToolDetector(store: files); + var manager = new HookManager(detector, files); + foreach (var status in detector.DetectAll().Where(value => value.HookInstalled && !value.IsHealthy)) + { + manager.Repair(status.Tool, bridge); + _logger.Info($"Outdated hook repaired: {status.Tool.DisplayName}"); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + _logger.Error("Automatic hook repair failed", ex); + } + } + + private void ExportDiagnostics() + { + var dialog = new Microsoft.Win32.SaveFileDialog + { + Filter = "CodeIsland diagnostics (*.zip)|*.zip", + FileName = $"codeisland-diagnostics-{DateTime.Now:yyyyMMdd-HHmmss}.zip", + DefaultExt = ".zip" + }; + if (dialog.ShowDialog() != true) return; + try + { + new DiagnosticsExporter().Export(dialog.FileName, _settings, Sessions, _logger.LogDirectory); + _logger.Info("Diagnostics exported."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.Error("Diagnostics export failed", ex); + System.Windows.MessageBox.Show(ex.Message, "CodeIsland", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + internal void OpenSettings() + { + if (_settingsWindow is { IsVisible: true }) + { + _settingsWindow.Activate(); + return; + } + _settingsWindow = new SettingsWindow(_settingsStore, _settings, ApplySettings, + () => _window?.HotKeyStatus ?? "Shortcuts are unavailable."); + _settingsWindow.Closed += (_, _) => _settingsWindow = null; + _settingsWindow.Show(); + } + + internal bool ToggleSound() + { + _settings = _settings with { SoundEnabled = !_settings.SoundEnabled }; + _settingsStore.Save(_settings); + _sounds.Enabled = _settings.SoundEnabled; + return _settings.SoundEnabled; + } + + private void ApplySettings(AppSettings settings) + { + _settings = settings; + _sounds.Enabled = settings.SoundEnabled; + L10n.Apply(Resources, settings.Language); + RebuildTrayMenu(); + _window?.ApplySettings(settings); + if (!settings.HideInFullscreen && _fullscreenSuppressed && _window is not null) + { + _window.Show(); + _fullscreenSuppressed = false; + } + } + + private void RebuildTrayMenu() + { + if (_tray is null) return; + _trayMenu?.Dispose(); + _trayMenu = TrayMenuFactory.Create(_settings.Language, ShowPanel, () => _window?.Hide(), OpenSettings, ExportDiagnostics, RequestExit); + _tray.ContextMenuStrip = _trayMenu; + } + + private void UpdateFullscreenVisibility() + { + if (_window is null || !_settings.HideInFullscreen) return; + if (DateTimeOffset.UtcNow < _manualShowUntil) return; + var fullscreen = FullscreenDetector.IsFullscreenForeground(_window.NativeHandle); + if (fullscreen && _window.IsVisible) + { + _window.Hide(); + _fullscreenSuppressed = true; + } + else if (!fullscreen && _fullscreenSuppressed) + { + _window.Show(); + _fullscreenSuppressed = false; + } + } + + private void StartPipeServer() + { + _pipeStop = new CancellationTokenSource(); + _pipeServer = new PipeServer(async (message, cancellationToken) => + { + if (message.Type == PipeMessageType.Event && message.Event is not null) + { + if (message.Event.Type is AgentEventType.PermissionRequest or AgentEventType.Question) + { + Task? responseTask = null; + Dispatcher.Invoke(() => { responseTask = Sessions.WaitForResponseAsync(message.Event, cancellationToken); }); + return await responseTask!; + } + Dispatcher.Invoke(() => Sessions.Apply(message.Event)); + return new PipeMessage(PipeMessageType.Ack, Guid.NewGuid().ToString("N"), AckFor: message.MessageId); + } + if (message.Type is PipeMessageType.Hello or PipeMessageType.Heartbeat) + return new PipeMessage(PipeMessageType.Ack, Guid.NewGuid().ToString("N"), AckFor: message.MessageId); + return new PipeMessage(PipeMessageType.Error, Guid.NewGuid().ToString("N"), Error: "Unsupported message."); + }); + _pipeTask = _pipeServer.RunAsync(_pipeStop.Token); + } + + private void ShowPanel() + { + if (_window is null) return; + _manualShowUntil = DateTimeOffset.UtcNow.AddSeconds(30); + _fullscreenSuppressed = false; + _window.Show(); + if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal; + _window.Activate(); + } + + internal void RequestExit() + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(RequestExit); + return; + } + if (_tray is not null) _tray.Visible = false; + Shutdown(); + } + + protected override void OnExit(ExitEventArgs e) + { + _pipeStop?.Cancel(); + _codexTailer?.Dispose(); + _logger.Info("Application exit."); + _cleanupTimer?.Stop(); + _fullscreenTimer?.Stop(); + if (_tray is not null) + { + _tray.Visible = false; + _tray.Dispose(); + _tray = null; + } + if (_pipeTask is { IsCompleted: true }) + { + try { _pipeTask.GetAwaiter().GetResult(); } + catch (OperationCanceledException) { } + } + _pipeServer?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _pipeStop?.Dispose(); + _appIcon?.Dispose(); + _showPanelRegistration?.Unregister(null); + _showPanelEvent?.Dispose(); + _instanceMutex?.Dispose(); + base.OnExit(e); + } +} + diff --git a/windows/CodeIsland.Windows/AppLogger.cs b/windows/CodeIsland.Windows/AppLogger.cs new file mode 100644 index 00000000..f61c0c4b --- /dev/null +++ b/windows/CodeIsland.Windows/AppLogger.cs @@ -0,0 +1,86 @@ +using System.IO; +using System.Text.RegularExpressions; + +namespace CodeIsland.Windows; + +public sealed class AppLogger +{ + private readonly object _gate = new(); + private string _filePath; + private readonly long _maxBytes; + public string LogDirectory => Path.GetDirectoryName(_filePath)!; + + public AppLogger(string? rootDirectory = null, long maxBytes = 1_000_000) + { + rootDirectory ??= Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodeIsland", "logs"); + _filePath = CreateLogPath(rootDirectory); + _maxBytes = maxBytes; + } + + public void Info(string message) => Write("INFO", message); + public void Error(string message, Exception? exception = null) => + Write("ERROR", exception is null ? message : $"{message}: {exception.GetType().Name}: {exception.Message}"); + + private void Write(string level, string message) + { + lock (_gate) + { + var line = $"{DateTimeOffset.UtcNow:O} [{level}] {SensitiveDataRedactor.Redact(message)}{Environment.NewLine}"; + try + { + RotateIfNeeded(); + File.AppendAllText(_filePath, line); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _filePath = CreateLogPath(Path.Combine(Path.GetTempPath(), "CodeIsland", "logs")); + try { File.AppendAllText(_filePath, line); } + catch (Exception fallbackError) when (fallbackError is IOException or UnauthorizedAccessException) { } + } + } + } + + private static string CreateLogPath(string directory) + { + try + { + Directory.CreateDirectory(directory); + return Path.Combine(directory, "codeisland.log"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + var fallback = Path.Combine(Path.GetTempPath(), "CodeIsland", "logs"); + Directory.CreateDirectory(fallback); + return Path.Combine(fallback, "codeisland.log"); + } + } + + private void RotateIfNeeded() + { + if (!File.Exists(_filePath) || new FileInfo(_filePath).Length < _maxBytes) return; + for (var i = 2; i >= 1; i--) + { + var source = i == 1 ? _filePath : _filePath + $".{i - 1}"; + var target = _filePath + $".{i}"; + if (File.Exists(source)) File.Move(source, target, true); + } + } +} + +public static partial class SensitiveDataRedactor +{ + public static string Redact(string value) + { + var result = BearerRegex().Replace(value, "Bearer [REDACTED]"); + result = SecretRegex().Replace(result, "[REDACTED_SECRET]"); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) result = result.Replace(home, "%USERPROFILE%", StringComparison.OrdinalIgnoreCase); + return result; + } + + [GeneratedRegex(@"(?i)Bearer\s+[A-Za-z0-9._~+/-]+=*")] + private static partial Regex BearerRegex(); + [GeneratedRegex(@"(?i)(sk-[A-Za-z0-9_-]{12,}|api[_-]?key\s*[:=]\s*\S+|token\s*[:=]\s*\S+)")] + private static partial Regex SecretRegex(); +} diff --git a/windows/CodeIsland.Windows/AppSettings.cs b/windows/CodeIsland.Windows/AppSettings.cs new file mode 100644 index 00000000..ad59fcd8 --- /dev/null +++ b/windows/CodeIsland.Windows/AppSettings.cs @@ -0,0 +1,32 @@ +namespace CodeIsland.Windows; + +public sealed record AppSettings +{ + public int Version { get; init; } = 1; + public string Language { get; init; } = "zh-CN"; + public string DisplayMode { get; init; } = "primary"; + public bool LaunchAtLogin { get; init; } + public bool SoundEnabled { get; init; } = true; + public int MaxVisibleSessions { get; init; } = 5; + public int EventHistoryLimit { get; init; } = 200; + public int SessionCleanupMinutes { get; init; } = 30; + public bool HideInFullscreen { get; init; } = true; + public string ToggleShortcut { get; init; } = "Ctrl+Shift+I"; + public string ApproveShortcut { get; init; } = "Ctrl+Shift+A"; + public string DenyShortcut { get; init; } = "Ctrl+Shift+D"; + + public AppSettings Validate() => this with + { + Language = Language is "zh-CN" or "en-US" or "system" ? Language : "system", + DisplayMode = DisplayMode is "primary" or "cursor" ? DisplayMode : "primary", + MaxVisibleSessions = Math.Clamp(MaxVisibleSessions, 1, 20), + EventHistoryLimit = Math.Clamp(EventHistoryLimit, 20, 2000), + SessionCleanupMinutes = Math.Clamp(SessionCleanupMinutes, 1, 1440), + ToggleShortcut = NormalizeShortcut(ToggleShortcut, "Ctrl+Shift+I"), + ApproveShortcut = NormalizeShortcut(ApproveShortcut, "Ctrl+Shift+A"), + DenyShortcut = NormalizeShortcut(DenyShortcut, "Ctrl+Shift+D") + }; + + private static string NormalizeShortcut(string value, string fallback) => + HotKeyBinding.TryParse(value, out var binding) ? binding.ToString() : fallback; +} diff --git a/windows/CodeIsland.Windows/AssemblyInfo.cs b/windows/CodeIsland.Windows/AssemblyInfo.cs new file mode 100644 index 00000000..cc29e7f7 --- /dev/null +++ b/windows/CodeIsland.Windows/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/windows/CodeIsland.Windows/BridgeLocator.cs b/windows/CodeIsland.Windows/BridgeLocator.cs new file mode 100644 index 00000000..f6fed0cf --- /dev/null +++ b/windows/CodeIsland.Windows/BridgeLocator.cs @@ -0,0 +1,28 @@ +using System.IO; + +namespace CodeIsland.Windows; + +public static class BridgeLocator +{ + public static string? Find(string baseDirectory, string? repositoryRoot = null) + { + var packaged = Path.Combine(baseDirectory, "CodeIsland.Bridge.exe"); + if (File.Exists(packaged)) return packaged; + if (repositoryRoot is not null) + { + foreach (var configuration in new[] { "Debug", "Release" }) + { + var development = Path.Combine(repositoryRoot, "CodeIsland.Bridge", "bin", configuration, + "net8.0", "CodeIsland.Bridge.exe"); + if (File.Exists(development)) return development; + } + } + return null; + } + + public static string? FindCurrent() + { + var root = Directory.GetParent(AppContext.BaseDirectory)?.Parent?.Parent?.Parent?.Parent?.FullName; + return Find(AppContext.BaseDirectory, root); + } +} diff --git a/windows/CodeIsland.Windows/CodeIsland.Windows.csproj b/windows/CodeIsland.Windows/CodeIsland.Windows.csproj new file mode 100644 index 00000000..5bfc6a15 --- /dev/null +++ b/windows/CodeIsland.Windows/CodeIsland.Windows.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + WinExe + net8.0-windows + enable + enable + true + true + ..\source\codeisland.ico + + + diff --git a/windows/CodeIsland.Windows/CodexSessionTailer.cs b/windows/CodeIsland.Windows/CodexSessionTailer.cs new file mode 100644 index 00000000..3050e3a2 --- /dev/null +++ b/windows/CodeIsland.Windows/CodexSessionTailer.cs @@ -0,0 +1,146 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Text; +using CodeIsland.Core; +using CodeIsland.Protocol; + +namespace CodeIsland.Windows; + +public sealed class CodexSessionTailer : IDisposable +{ + private readonly string _rootDirectory; + private readonly ConcurrentDictionary _positions = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _contexts = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _locks = new(StringComparer.OrdinalIgnoreCase); + private FileSystemWatcher? _watcher; + private System.Threading.Timer? _pollTimer; + public event EventHandler? EventReceived; + + public CodexSessionTailer(string? rootDirectory = null) + { + var codexHome = Environment.GetEnvironmentVariable("CODEX_HOME"); + if (string.IsNullOrWhiteSpace(codexHome)) + codexHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); + _rootDirectory = rootDirectory ?? Path.Combine(codexHome, "sessions"); + } + + public void Start(TimeSpan? activeWindow = null) + { + if (!Directory.Exists(_rootDirectory) || _watcher is not null) return; + var cutoff = DateTime.UtcNow - (activeWindow ?? TimeSpan.FromMinutes(30)); + foreach (var file in Directory.GetFiles(_rootDirectory, "*.jsonl", SearchOption.AllDirectories) + .Select(path => new FileInfo(path)).Where(file => file.LastWriteTimeUtc >= cutoff) + .OrderByDescending(file => file.LastWriteTimeUtc).Take(10)) + ReadInitial(file.FullName); + + _watcher = new FileSystemWatcher(_rootDirectory, "*.jsonl") + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, + EnableRaisingEvents = true + }; + _watcher.Changed += OnChanged; + _watcher.Created += OnChanged; + _watcher.Renamed += (_, args) => ReadNew(args.FullPath); + _pollTimer = new System.Threading.Timer(_ => + { + foreach (var path in _positions.Keys) ReadNew(path); + }, null, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); + } + + private void OnChanged(object sender, FileSystemEventArgs args) => ReadNew(args.FullPath); + + private void ReadInitial(string path) + { + lock (_locks.GetOrAdd(path, _ => new object())) + { + var context = _contexts.GetOrAdd(path, _ => new CodexTranscriptContext()); + AgentEvent? sessionStart = null; + AgentEvent? last = null; + using var stream = OpenShared(path); + using var reader = new StreamReader(stream); + while (reader.ReadLine() is { } line) + { + var parsed = TryParse(line, context); + if (parsed?.Type == AgentEventType.SessionStart) sessionStart ??= parsed; + if (parsed is not null) last = parsed; + } + _positions[path] = CompleteLength(stream); + if (sessionStart is not null) EventReceived?.Invoke(this, sessionStart); + if (last is not null && last != sessionStart) EventReceived?.Invoke(this, last); + } + } + + private void ReadNew(string path) + { + if (!File.Exists(path)) return; + lock (_locks.GetOrAdd(path, _ => new object())) + { + try + { + using var stream = OpenShared(path); + var position = _positions.GetOrAdd(path, 0); + if (position > stream.Length) position = 0; + var available = stream.Length - position; + if (available <= 0) return; + if (available > int.MaxValue) position = Math.Max(0, stream.Length - 1024 * 1024); + stream.Seek(position, SeekOrigin.Begin); + var buffer = new byte[checked((int)(stream.Length - position))]; + var read = 0; + while (read < buffer.Length) + { + var count = stream.Read(buffer, read, buffer.Length - read); + if (count == 0) break; + read += count; + } + var completeLength = Array.LastIndexOf(buffer, (byte)'\n', read - 1) + 1; + if (completeLength == 0) return; + var context = _contexts.GetOrAdd(path, _ => new CodexTranscriptContext()); + var completeText = Encoding.UTF8.GetString(buffer, 0, completeLength); + foreach (var line in completeText.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var parsed = TryParse(line.TrimEnd('\r'), context); + if (parsed is not null) EventReceived?.Invoke(this, parsed); + } + _positions[path] = position + completeLength; + } + catch (IOException) { } + } + } + + private static AgentEvent? TryParse(string line, CodexTranscriptContext context) + { + try { return CodexTranscriptParser.ParseLine(line, context); } + catch (System.Text.Json.JsonException) { return null; } + } + + private static FileStream OpenShared(string path) => + new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + private static long CompleteLength(FileStream stream) + { + if (stream.Length == 0) return 0; + var original = stream.Position; + for (var offset = stream.Length - 1; offset >= 0; offset--) + { + stream.Position = offset; + if (stream.ReadByte() == '\n') + { + stream.Position = original; + return offset + 1; + } + } + stream.Position = original; + return 0; + } + + public void Dispose() + { + if (_watcher is null) return; + _watcher.EnableRaisingEvents = false; + _watcher.Dispose(); + _watcher = null; + _pollTimer?.Dispose(); + _pollTimer = null; + } +} diff --git a/windows/CodeIsland.Windows/DesktopSessionStore.cs b/windows/CodeIsland.Windows/DesktopSessionStore.cs new file mode 100644 index 00000000..8ddef43f --- /dev/null +++ b/windows/CodeIsland.Windows/DesktopSessionStore.cs @@ -0,0 +1,139 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using CodeIsland.Core; +using CodeIsland.Ipc; + +namespace CodeIsland.Windows; + +public sealed class DesktopSessionStore : INotifyPropertyChanged +{ + private readonly SessionStateMachine _machine = new(); + private readonly Dictionary _pending = new(StringComparer.Ordinal); + private readonly int _historyLimit; + private readonly int _maxVisibleSessions; + public ObservableCollection Sessions { get; } = []; + public ObservableCollection EventHistory { get; } = []; + public event EventHandler? EventApplied; + public int SessionCount => Sessions.Count; + public bool HasSessions => Sessions.Count > 0; + public bool IsIdle => !HasSessions; + public SessionSnapshot? CurrentSession => Sessions.FirstOrDefault(value => + value.State is not (SessionState.Completed or SessionState.Cancelled or SessionState.Failed)) + ?? Sessions.FirstOrDefault(); + + public DesktopSessionStore(int maxVisibleSessions = 5, int historyLimit = 200) + { + if (maxVisibleSessions < 1) throw new ArgumentOutOfRangeException(nameof(maxVisibleSessions)); + if (historyLimit < 1) throw new ArgumentOutOfRangeException(nameof(historyLimit)); + _maxVisibleSessions = maxVisibleSessions; + _historyLimit = historyLimit; + } + + public void Apply(AgentEvent agentEvent) + { + if (!_machine.Apply(agentEvent) || !_machine.TryGet(agentEvent.SessionId, out var snapshot) || snapshot is null) + return; + EventHistory.Insert(0, agentEvent); + while (EventHistory.Count > _historyLimit) EventHistory.RemoveAt(EventHistory.Count - 1); + var existing = Sessions.Select((value, index) => (value, index)) + .FirstOrDefault(pair => pair.value.SessionId == snapshot.SessionId); + if (existing.value is null) Sessions.Insert(0, snapshot); + else Sessions[existing.index] = snapshot; + while (Sessions.Count > _maxVisibleSessions) Sessions.RemoveAt(Sessions.Count - 1); + OnPropertyChanged(nameof(SessionCount)); + OnPropertyChanged(nameof(HasSessions)); + OnPropertyChanged(nameof(IsIdle)); + OnPropertyChanged(nameof(CurrentSession)); + EventApplied?.Invoke(this, agentEvent); + } + + public Task WaitForResponseAsync(AgentEvent agentEvent, CancellationToken cancellationToken) + { + Apply(agentEvent); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[agentEvent.EventId] = new PendingResponse(agentEvent.SessionId, pending); + cancellationToken.Register(() => pending.TrySetCanceled(cancellationToken)); + return pending.Task; + } + + public bool Resolve(string eventId, UserAction action, string? responseText = null) + { + if (!_pending.Remove(eventId, out var pending)) return false; + _machine.ResolvePending(pending.SessionId, eventId, DateTimeOffset.UtcNow); + if (_machine.TryGet(pending.SessionId, out var snapshot) && snapshot is not null) ReplaceVisible(snapshot); + return pending.Completion.TrySetResult(new PipeMessage( + PipeMessageType.ActionResponse, + Guid.NewGuid().ToString("N"), + AckFor: eventId, + Action: action, + ResponseText: responseText)); + } + + public bool ResolveCurrent(UserAction action) + { + var pending = Sessions.FirstOrDefault(value => + value.State is SessionState.WaitingForPermission or SessionState.WaitingForAnswer); + return pending?.PendingEventId is { } eventId && Resolve(eventId, action); + } + + public int PendingCount => _pending.Count; + + public int RemoveExpired(DateTimeOffset cutoff) + { + var removed = _machine.RemoveExpired(cutoff); + var activeIds = _machine.Sessions.Select(value => value.SessionId).ToHashSet(StringComparer.Ordinal); + for (var i = Sessions.Count - 1; i >= 0; i--) + if (!activeIds.Contains(Sessions[i].SessionId)) Sessions.RemoveAt(i); + foreach (var eventId in _pending.Where(pair => !activeIds.Contains(pair.Value.SessionId)) + .Select(pair => pair.Key).ToArray()) + { + if (_pending.Remove(eventId, out var pending)) pending.Completion.TrySetCanceled(); + } + OnPropertyChanged(nameof(SessionCount)); + OnPropertyChanged(nameof(HasSessions)); + OnPropertyChanged(nameof(IsIdle)); + OnPropertyChanged(nameof(CurrentSession)); + return removed; + } + + public bool RemoveSession(string sessionId) + { + if (!_machine.Remove(sessionId)) return false; + var visible = Sessions.FirstOrDefault(value => value.SessionId == sessionId); + if (visible is not null) Sessions.Remove(visible); + foreach (var eventId in _pending.Where(pair => pair.Value.SessionId == sessionId) + .Select(pair => pair.Key).ToArray()) + { + if (_pending.Remove(eventId, out var pending)) pending.Completion.TrySetCanceled(); + } + OnPropertyChanged(nameof(SessionCount)); + OnPropertyChanged(nameof(HasSessions)); + OnPropertyChanged(nameof(IsIdle)); + OnPropertyChanged(nameof(CurrentSession)); + return true; + } + + public bool MoveSessionBefore(string sessionId, string targetSessionId) + { + var sourceIndex = Sessions.ToList().FindIndex(value => value.SessionId == sessionId); + var targetIndex = Sessions.ToList().FindIndex(value => value.SessionId == targetSessionId); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex) return false; + Sessions.Move(sourceIndex, targetIndex); + OnPropertyChanged(nameof(CurrentSession)); + return true; + } + + private void ReplaceVisible(SessionSnapshot snapshot) + { + var index = Sessions.ToList().FindIndex(value => value.SessionId == snapshot.SessionId); + if (index >= 0) Sessions[index] = snapshot; + OnPropertyChanged(nameof(CurrentSession)); + } + + private sealed record PendingResponse(string SessionId, TaskCompletionSource Completion); + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/windows/CodeIsland.Windows/DiagnosticsExporter.cs b/windows/CodeIsland.Windows/DiagnosticsExporter.cs new file mode 100644 index 00000000..57e53b98 --- /dev/null +++ b/windows/CodeIsland.Windows/DiagnosticsExporter.cs @@ -0,0 +1,61 @@ +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using CodeIsland.Hooks; + +namespace CodeIsland.Windows; + +public sealed class DiagnosticsExporter +{ + public void Export(string destinationPath, AppSettings settings, DesktopSessionStore sessions, string logDirectory) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(destinationPath))!); + using var file = File.Create(destinationPath); + using var archive = new ZipArchive(file, ZipArchiveMode.Create); + var hooks = DetectHooksSafely(); + var summary = new + { + generatedAtUtc = DateTimeOffset.UtcNow, + applicationVersion = "0.1.0", + operatingSystem = Environment.OSVersion.VersionString, + runtime = Environment.Version.ToString(), + processArchitecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(), + settings = settings with { LaunchAtLogin = false }, + visibleSessionCount = sessions.SessionCount, + eventHistoryCount = sessions.EventHistory.Count, + hooks + }; + WriteEntry(archive, "diagnostics.json", JsonSerializer.Serialize(summary, + new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true })); + if (!Directory.Exists(logDirectory)) return; + foreach (var log in Directory.GetFiles(logDirectory, "codeisland.log*")) + WriteEntry(archive, $"logs/{Path.GetFileName(log)}", SensitiveDataRedactor.Redact(File.ReadAllText(log))); + } + + private static object[] DetectHooksSafely() + { + try + { + return new ToolDetector().DetectAll().Select(status => (object)new + { + tool = status.Tool.DisplayName, + executableFound = status.ExecutablePath is not null, + status.HookInstalled, + status.IsHealthy, + status.Problem + }).ToArray(); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + return [new { tool = "unavailable", error = SensitiveDataRedactor.Redact(ex.Message) }]; + } + } + + private static void WriteEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name, CompressionLevel.Optimal); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(content); + } +} diff --git a/windows/CodeIsland.Windows/DisplayPositioner.cs b/windows/CodeIsland.Windows/DisplayPositioner.cs new file mode 100644 index 00000000..ae8d0609 --- /dev/null +++ b/windows/CodeIsland.Windows/DisplayPositioner.cs @@ -0,0 +1,30 @@ +using System.Drawing; +using System.Windows.Forms; + +namespace CodeIsland.Windows; + +public static class DisplayPositioner +{ + public static Rectangle SelectWorkingArea(string mode) + { + var screen = mode.Equals("cursor", StringComparison.OrdinalIgnoreCase) + ? Screen.FromPoint(Cursor.Position) + : Screen.PrimaryScreen; + return screen?.WorkingArea ?? new Rectangle(0, 0, 1920, 1080); + } + + public static Rectangle WorkingAreaForWindow(IntPtr windowHandle) => + windowHandle == IntPtr.Zero + ? Screen.PrimaryScreen?.WorkingArea ?? new Rectangle(0, 0, 1920, 1080) + : Screen.FromHandle(windowHandle).WorkingArea; + + public static (double Left, double Top) TopCenter(Rectangle workingAreaPixels, + double dpiScaleX, double dpiScaleY, double widthDip, double topDip = 14) + { + if (dpiScaleX <= 0 || dpiScaleY <= 0) throw new ArgumentOutOfRangeException(nameof(dpiScaleX)); + var left = workingAreaPixels.Left / dpiScaleX + + (workingAreaPixels.Width / dpiScaleX - widthDip) / 2; + var top = workingAreaPixels.Top / dpiScaleY + topDip; + return (left, top); + } +} diff --git a/windows/CodeIsland.Windows/DockShoulderGeometry.cs b/windows/CodeIsland.Windows/DockShoulderGeometry.cs new file mode 100644 index 00000000..9f8171cd --- /dev/null +++ b/windows/CodeIsland.Windows/DockShoulderGeometry.cs @@ -0,0 +1,64 @@ +using System.Windows; +using System.Windows.Media; +using WpfPoint = System.Windows.Point; +using WpfSize = System.Windows.Size; + +namespace CodeIsland.Windows; + +public static class DockShoulderGeometry +{ + public static (Geometry First, Geometry Second) Create(WpfSize size, DockEdges edges, double depth = 38, double curve = 28) + { + if (edges.HasFlag(DockEdges.Top)) + return (VerticalShoulder(depth, size.Height, curve, left: true, top: true, size.Width), + VerticalShoulder(depth, size.Height, curve, left: false, top: true, size.Width)); + if (edges.HasFlag(DockEdges.Bottom)) + return (VerticalShoulder(depth, size.Height, curve, left: true, top: false, size.Width), + VerticalShoulder(depth, size.Height, curve, left: false, top: false, size.Width)); + if (edges.HasFlag(DockEdges.Left)) + return (HorizontalShoulder(size.Width, depth, curve, top: true, left: true, size.Height), + HorizontalShoulder(size.Width, depth, curve, top: false, left: true, size.Height)); + if (edges.HasFlag(DockEdges.Right)) + return (HorizontalShoulder(size.Width, depth, curve, top: true, left: false, size.Height), + HorizontalShoulder(size.Width, depth, curve, top: false, left: false, size.Height)); + return (Geometry.Empty, Geometry.Empty); + } + + private static Geometry VerticalShoulder(double width, double height, double curve, bool left, bool top, double panelWidth) + { + var x0 = left ? 0 : panelWidth - width; + var inner = left ? x0 + width : x0; + var outer = left ? x0 : x0 + width; + var edgeY = top ? 0 : height; + var taperY = top ? curve : height - curve; + var geometry = new StreamGeometry(); + using var context = geometry.Open(); + context.BeginFigure(new WpfPoint(inner, edgeY), true, true); + context.LineTo(new WpfPoint(outer, edgeY), true, false); + context.BezierTo( + new WpfPoint(outer + (inner - outer) * .35, edgeY), + new WpfPoint(inner, taperY + (edgeY - taperY) * .35), + new WpfPoint(inner, taperY), true, false); + geometry.Freeze(); + return geometry; + } + + private static Geometry HorizontalShoulder(double width, double height, double curve, bool top, bool left, double panelHeight) + { + var y0 = top ? 0 : panelHeight - height; + var inner = top ? y0 + height : y0; + var outer = top ? y0 : y0 + height; + var edgeX = left ? 0 : width; + var taperX = left ? curve : width - curve; + var geometry = new StreamGeometry(); + using var context = geometry.Open(); + context.BeginFigure(new WpfPoint(edgeX, inner), true, true); + context.LineTo(new WpfPoint(edgeX, outer), true, false); + context.BezierTo( + new WpfPoint(edgeX, outer + (inner - outer) * .35), + new WpfPoint(taperX + (edgeX - taperX) * .35, inner), + new WpfPoint(taperX, inner), true, false); + geometry.Freeze(); + return geometry; + } +} diff --git a/windows/CodeIsland.Windows/FullscreenDetector.cs b/windows/CodeIsland.Windows/FullscreenDetector.cs new file mode 100644 index 00000000..bc75a607 --- /dev/null +++ b/windows/CodeIsland.Windows/FullscreenDetector.cs @@ -0,0 +1,55 @@ +using System.Drawing; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace CodeIsland.Windows; + +[SupportedOSPlatform("windows")] +public static class FullscreenDetector +{ + public static bool IsFullscreenForeground(IntPtr ignoredWindow) + { + var foreground = GetForegroundWindow(); + if (foreground == IntPtr.Zero || foreground == ignoredWindow) return false; + if (IsShellSurface(foreground)) return false; + if (!GetWindowRect(foreground, out var window)) return false; + var monitor = MonitorFromWindow(foreground, MonitorDefaultToNearest); + var ignoredMonitor = MonitorFromWindow(ignoredWindow, MonitorDefaultToNearest); + if (monitor == IntPtr.Zero || ignoredMonitor == IntPtr.Zero || monitor != ignoredMonitor) return false; + var info = new MonitorInfo { Size = Marshal.SizeOf() }; + if (!NativeGetMonitorInfo(monitor, ref info)) return false; + return IsSameBounds(ToRectangle(window), ToRectangle(info.Monitor)); + } + + public static bool IsSameBounds(Rectangle window, Rectangle monitor) => + window.Left <= monitor.Left && window.Top <= monitor.Top + && window.Right >= monitor.Right && window.Bottom >= monitor.Bottom; + + private const uint MonitorDefaultToNearest = 2; + private static bool IsShellSurface(IntPtr window) + { + if (window == GetShellWindow()) return true; + var className = new System.Text.StringBuilder(64); + if (GetClassName(window, className, className.Capacity) == 0) return false; + return className.ToString() is "Progman" or "WorkerW" or "Shell_TrayWnd"; + } + + [StructLayout(LayoutKind.Sequential)] private struct NativeRect { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] private struct MonitorInfo + { + public int Size; + public NativeRect Monitor; + public NativeRect Work; + public uint Flags; + } + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern IntPtr GetShellWindow(); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr handle, System.Text.StringBuilder className, int maxCount); + [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr handle, out NativeRect rect); + [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMonitorInfoW")] + private static extern bool NativeGetMonitorInfo(IntPtr monitor, ref MonitorInfo info); + private static Rectangle ToRectangle(NativeRect rect) => + Rectangle.FromLTRB(rect.Left, rect.Top, rect.Right, rect.Bottom); +} diff --git a/windows/CodeIsland.Windows/GlobalHotKeyManager.cs b/windows/CodeIsland.Windows/GlobalHotKeyManager.cs new file mode 100644 index 00000000..1e3d6042 --- /dev/null +++ b/windows/CodeIsland.Windows/GlobalHotKeyManager.cs @@ -0,0 +1,114 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Windows.Interop; + +namespace CodeIsland.Windows; + +[SupportedOSPlatform("windows")] +public sealed class GlobalHotKeyManager : IDisposable +{ + private const int WmHotKey = 0x0312; + private readonly HwndSource _source; + private readonly Action _toggle; + private readonly Action _approve; + private readonly Action _deny; + private bool _disposed; + + public bool ToggleRegistered { get; } + public bool ApproveRegistered { get; } + public bool DenyRegistered { get; } + public string RegistrationSummary => + $"Toggle: {Label(ToggleRegistered)}, Approve: {Label(ApproveRegistered)}, Deny: {Label(DenyRegistered)}"; + + private static string Label(bool registered) => registered ? "registered" : "conflict or unavailable"; + + public GlobalHotKeyManager(HwndSource source, AppSettings settings, Action toggle, Action approve, Action deny) + { + _source = source; + _toggle = toggle; + _approve = approve; + _deny = deny; + _source.AddHook(WindowProc); + ToggleRegistered = Register(1, settings.ToggleShortcut); + ApproveRegistered = Register(2, settings.ApproveShortcut); + DenyRegistered = Register(3, settings.DenyShortcut); + } + + private bool Register(int id, string value) => HotKeyBinding.TryParse(value, out var binding) + && RegisterHotKey(_source.Handle, id, binding.Modifiers, binding.VirtualKey); + + private IntPtr WindowProc(IntPtr hwnd, int message, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (message != WmHotKey) return IntPtr.Zero; + switch (wParam.ToInt32()) + { + case 1: _toggle(); handled = true; break; + case 2: _approve(); handled = true; break; + case 3: _deny(); handled = true; break; + } + return IntPtr.Zero; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _source.RemoveHook(WindowProc); + UnregisterHotKey(_source.Handle, 1); + UnregisterHotKey(_source.Handle, 2); + UnregisterHotKey(_source.Handle, 3); + } + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); +} + +public readonly record struct HotKeyBinding(uint Modifiers, uint VirtualKey) +{ + private const uint Alt = 0x0001; + private const uint Control = 0x0002; + private const uint Shift = 0x0004; + private const uint Win = 0x0008; + + public static bool TryParse(string? value, out HotKeyBinding binding) + { + binding = default; + if (string.IsNullOrWhiteSpace(value)) return false; + var parts = value.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 2) return false; + uint modifiers = 0; + foreach (var modifier in parts[..^1]) + { + modifiers |= modifier.ToLowerInvariant() switch + { + "ctrl" or "control" => Control, + "alt" => Alt, + "shift" => Shift, + "win" or "windows" => Win, + _ => 0 + }; + } + if (modifiers == 0 || parts[..^1].Any(part => part.ToLowerInvariant() is not + ("ctrl" or "control" or "alt" or "shift" or "win" or "windows"))) return false; + var keyText = parts[^1].ToUpperInvariant(); + if (keyText.Length != 1 || !char.IsAsciiLetterOrDigit(keyText[0])) return false; + binding = new HotKeyBinding(modifiers, keyText[0]); + return true; + } + + public override string ToString() + { + var parts = new List(); + if ((Modifiers & Control) != 0) parts.Add("Ctrl"); + if ((Modifiers & Alt) != 0) parts.Add("Alt"); + if ((Modifiers & Shift) != 0) parts.Add("Shift"); + if ((Modifiers & Win) != 0) parts.Add("Win"); + parts.Add(((char)VirtualKey).ToString()); + return string.Join('+', parts); + } +} diff --git a/windows/CodeIsland.Windows/L10n.cs b/windows/CodeIsland.Windows/L10n.cs new file mode 100644 index 00000000..6f64f20f --- /dev/null +++ b/windows/CodeIsland.Windows/L10n.cs @@ -0,0 +1,22 @@ +using System.Globalization; +using System.Windows; + +namespace CodeIsland.Windows; + +public static class L10n +{ + private static readonly IReadOnlyDictionary> Values = new Dictionary>(StringComparer.Ordinal) + { + ["en-US"] = new Dictionary { + ["IdleText"]="Waiting for AI coding sessions", ["ApproveText"]="Approve", ["DenyText"]="Deny", ["AlwaysAllowText"]="Always allow", ["SendText"]="Send", ["CloseSessionText"]="Close session", + ["TrayOpenText"]="Open panel", ["TrayCollapseText"]="Collapse panel", ["TraySettingsText"]="Settings", ["TrayDiagnosticsText"]="Export diagnostics", ["TrayExitText"]="Exit" + }, + ["zh-CN"] = new Dictionary { + ["IdleText"]="等待 AI 编程会话", ["ApproveText"]="允许", ["DenyText"]="拒绝", ["AlwaysAllowText"]="始终允许", ["SendText"]="发送", ["CloseSessionText"]="关闭会话", + ["TrayOpenText"]="打开面板", ["TrayCollapseText"]="收起面板", ["TraySettingsText"]="设置", ["TrayDiagnosticsText"]="导出诊断", ["TrayExitText"]="退出" + } + }; + public static string ResolveLanguage(string configuredLanguage) => configuredLanguage is "en-US" or "zh-CN" ? configuredLanguage : "zh-CN"; + public static void Apply(ResourceDictionary resources, string configuredLanguage) { foreach (var pair in Values[ResolveLanguage(configuredLanguage)]) resources[pair.Key] = pair.Value; } + public static string Get(string key, string language) => Values[ResolveLanguage(language)][key]; +} diff --git a/windows/CodeIsland.Windows/MainWindow.xaml b/windows/CodeIsland.Windows/MainWindow.xaml new file mode 100644 index 00000000..e97cbfe5 --- /dev/null +++ b/windows/CodeIsland.Windows/MainWindow.xaml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +