diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index 92b4640..5a90871 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "codex-session-grid",
- "version": "0.1.0-beta.1",
- "description": "Beta: a Codex-styled 2x2 and 3x3 workspace with live session sync, voice input, guided prompts, and model controls.",
+ "version": "0.2.0-beta.1+codex.20260717125754",
+ "description": "Beta: a cross-platform Codex-styled 2x2 and 3x3 workspace with live session sync and native single-send bridges.",
"author": {
"name": "\u8def\u706f\u540c\u5b66\u521b\u4e1a\u7b14\u8bb0",
"url": "https://github.com/streetlightstartupnotes"
@@ -13,13 +13,14 @@
"codex",
"multi-session",
"dashboard",
- "macos"
+ "macos",
+ "windows"
],
"skills": "./skills/",
"interface": {
"displayName": "Codex \u591a\u4efb\u52a1\u5bab\u683c",
"shortDescription": "Codex \u539f\u751f\u98ce\u683c\u7684\u591a\u4efb\u52a1\u5bab\u683c\u4e0e\u6a21\u578b\u63a7\u5236\u53f0\u3002",
- "longDescription": "\u542f\u52a8\u4e00\u4e2a\u4ec5\u76d1\u542c\u672c\u673a\u7684 Codex \u98ce\u683c\u591a\u4efb\u52a1\u5de5\u4f5c\u53f0\uff0c\u4ee5 2x2 \u6216 3x3 \u7f51\u683c\u67e5\u770b\u771f\u5b9e Session\uff0c\u5e76\u63d0\u4f9b\u8bed\u97f3\u8f93\u5165\u3001\u5f15\u5bfc\u6307\u4ee4\u3001\u6a21\u578b\u3001\u54cd\u5e94\u901f\u5ea6\u548c\u63a8\u7406\u5f3a\u5ea6\u63a7\u5236\u3002",
+ "longDescription": "\u542f\u52a8\u4e00\u4e2a\u4ec5\u76d1\u542c\u672c\u673a\u7684 Codex \u98ce\u683c\u591a\u4efb\u52a1\u5de5\u4f5c\u53f0\uff0c\u4ee5 2x2 \u6216 3x3 \u7f51\u683c\u67e5\u770b\u771f\u5b9e Session\uff0c\u5e76\u901a\u8fc7 macOS \u6216 Windows \u539f\u751f\u6865\u63a5\u53ea\u53d1\u9001\u4e00\u6b21\uff1b\u540c\u65f6\u63d0\u4f9b\u8bed\u97f3\u8f93\u5165\u3001\u5f15\u5bfc\u6307\u4ee4\u548c\u6a21\u578b\u63a7\u4ef6\u3002",
"developerName": "\u8def\u706f\u540c\u5b66\u521b\u4e1a\u7b14\u8bb0",
"websiteURL": "https://github.com/streetlightstartupnotes",
"category": "Productivity",
diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml
new file mode 100644
index 0000000..89f60a5
--- /dev/null
+++ b/.github/workflows/cross-platform.yml
@@ -0,0 +1,59 @@
+name: Cross-platform checks
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ macos:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - name: Check JavaScript and shell scripts
+ run: |
+ node --check scripts/server.mjs
+ node --check public/app.js
+ sh -n scripts/start.sh scripts/stop.sh
+ - name: Type-check macOS bridge
+ run: swiftc -typecheck scripts/native-bridge.swift
+
+ windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - name: Check JavaScript
+ run: |
+ node --check scripts/server.mjs
+ node --check public/app.js
+ - name: Parse PowerShell scripts
+ shell: powershell
+ run: |
+ $failed = $false
+ foreach ($path in @(
+ "scripts/start.ps1",
+ "scripts/stop.ps1",
+ "scripts/diagnose-windows.ps1",
+ "scripts/native-bridge-windows.ps1"
+ )) {
+ $tokens = $null
+ $errors = $null
+ [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) | Out-Null
+ if ($errors.Count -gt 0) {
+ $failed = $true
+ $errors | ForEach-Object { Write-Error "$path`: $($_.Message)" }
+ }
+ }
+ if ($failed) { throw "PowerShell parsing failed" }
+ - name: Compile and probe Windows UI Automation bridge
+ shell: powershell
+ run: |
+ $json = & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/native-bridge-windows.ps1 -Action probe
+ $status = $json | ConvertFrom-Json
+ if (-not $status.ok) { throw $status.error }
+ if (-not $status.supported -or $status.platform -ne "windows") { throw "Unexpected Windows bridge status" }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 848b25f..c673432 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
本项目采用 [Semantic Versioning](https://semver.org/) 记录版本。
+## [0.2.0-beta.1] - Unreleased
+
+macOS / Windows 同步兼容测试版。macOS 主流程保持不变,Windows 原生交互等待真实设备验证。
+
+### 新增
+
+- 服务端按 `process.platform` 自动选择 macOS Swift 或 Windows C# UI Automation 桥。
+- Windows PowerShell 启动、停止与脱敏诊断脚本。
+- Windows 路径缩写、平台状态提示和权限等级引导。
+- GitHub Actions 的 macOS/Windows 跨平台静态检查与 Windows 桥编译探针。
+
+### 保持不变
+
+- 网页发送仍只允许原生桥接提交一次,不增加 Sidecar `turn/start` 降级路径。
+- macOS Accessibility 单次发送、Session 轮询与工具活动同步逻辑继续保留。
+
## [0.1.0-beta.1] - 2026-07-17
首个公开测试版,已在作者本机 macOS + Codex Desktop 环境完成主要流程验证。
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f7c9d10..1f8667a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,10 +6,10 @@
请说明:
-- macOS、Codex Desktop、Node.js 与浏览器版本。
+- 操作系统、Codex Desktop、Node.js 与浏览器版本。
- 使用四宫格还是九宫格,以及可复现的操作步骤。
- 预期结果和实际结果。
-- 是否已授予 Codex 辅助功能权限、浏览器麦克风权限。
+- macOS 是否已授予辅助功能权限;Windows 是否保持相同权限等级;浏览器是否有麦克风权限。
请勿上传未脱敏的 Session 内容、账号信息、访问令牌或完整本机路径。
@@ -24,6 +24,13 @@ sh -n scripts/start.sh scripts/stop.sh
swiftc -typecheck scripts/native-bridge.swift
```
+Windows 修改还要在 Windows PowerShell 中解析全部 `.ps1`,并运行:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/native-bridge-windows.ps1 -Action probe
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/diagnose-windows.ps1
+```
+
涉及发送流程的修改还必须验证:网页只触发一次原生发送、Codex App 只出现一条用户消息、网页从同一 Session 同步回复。不要增加 Sidecar `turn/start` 作为降级发送方式。
## Pull Request
diff --git a/README.md b/README.md
index 5d77fbd..243b7d4 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Codex Session Grid
-> **Beta 测试版 · v0.1.0-beta.1**
+> **跨平台 Beta 测试版 · v0.2.0-beta.1**
>
-> 已在作者本机的 macOS + Codex Desktop 环境跑通。当前版本用于公开测试,不建议用于关键生产任务。
+> macOS 主流程已在作者本机跑通;Windows 兼容层已经完成,等待真实 Windows 11 + Codex Desktop 环境验证。当前版本不建议用于关键生产任务。
一个保持 Codex 视觉风格的多任务工作台,可用四宫格或九宫格同时查看、发送和跟踪多个真实 Codex Session。它是独立开发的本地辅助工具,并非 OpenAI 官方产品。
@@ -12,7 +12,7 @@
- 四宫格、九宫格展示多个 Codex 任务,支持筛选、折叠、拖拽换位和单任务跳回 Codex App。
- 同步用户消息、Assistant 回复、工具调用、已查看图像、已读取文件等 Session 动态。
-- 网页发出的消息由 macOS 原生桥接提交到对应 Codex App 任务,再从同一个 Session 同步回来。
+- 网页发出的消息在 macOS 走 Swift Accessibility,在 Windows 走 C# UI Automation,再从同一个 Session 同步回来。
- 运行中的任务显示绿色状态点;消息区按 Markdown 自动排版。
- 支持浏览器语音输入、引导提示词、模型、速度和推理强度控件。
- 服务只监听 `127.0.0.1`,复用本机已有的 Codex 登录,不需要填写 API Key。
@@ -25,7 +25,7 @@
flowchart LR
A["浏览器宫格"] -->|"读取任务与进度"| B["本机 Sidecar"]
B --> C["Codex Session / app-server"]
- A -->|"发送一条消息"| D["macOS 原生桥接"]
+ A -->|"发送一条消息"| D["macOS / Windows 原生桥接"]
D --> E["Codex App 对应任务输入框"]
E -->|"只发送一次"| C
C -->|"同一 Session 的消息、回复、工具状态"| A
@@ -35,19 +35,33 @@ flowchart LR
## 环境要求
-- macOS 与已登录的 Codex Desktop App。
-- Node.js,建议使用当前 LTS 版本。
-- Xcode Command Line Tools 提供的 `swiftc`,用于首次编译原生单次发送桥接。
-- 原生发送需要在“系统设置 → 隐私与安全性 → 辅助功能”中允许 Codex。
-- 语音输入需要浏览器支持 Web Speech API,并允许麦克风权限;Codex 内置浏览器不支持时,请改用 Chrome 或 Safari。
+两端都需要已登录的 Codex Desktop App 和 Node.js(建议当前 LTS)。语音输入需要浏览器支持 Web Speech API,并允许麦克风权限。
+
+macOS 还需要:
+
+- Xcode Command Line Tools 提供的 `swiftc`。
+- 在“系统设置 → 隐私与安全性 → 辅助功能”中允许 Codex。
+
+Windows 还需要:
+
+- Windows 11、Windows PowerShell 5.1 和 Windows 原生 Codex/ChatGPT Desktop App。
+- 工作台与 Codex App 使用相同权限等级;如果 App 以管理员身份运行,也要从管理员 PowerShell 启动工作台。
+- 推荐使用 Edge 或 Chrome 进行语音输入。
> 安装或更新插件后,建议新建一个 Codex 任务,让更新后的插件技能完整生效。
## 本地运行
+先克隆仓库:
+
```bash
git clone https://github.com/streetlightstartupnotes/codex-session-grid.git
cd codex-session-grid
+```
+
+macOS:
+
+```bash
bash scripts/start.sh
```
@@ -64,24 +78,45 @@ CODEX_SESSION_GRID_PORT=4318 bash scripts/start.sh
CODEX_SESSION_GRID_PORT=4318 bash scripts/stop.sh
```
+Windows PowerShell:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/start.ps1
+```
+
+停止服务:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/stop.ps1
+```
+
+更换端口可以传入 `-Port 4318`。Windows 首次测试建议同时运行诊断脚本:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/diagnose-windows.ps1
+```
+
+完整 Windows 验证步骤见 [docs/WINDOWS_TESTING.md](docs/WINDOWS_TESTING.md)。
+
仓库当前按单插件源码结构发布,可直接用上面的脚本运行。Codex marketplace 一键安装结构将在后续版本补充;本机已有个人 marketplace 的开发者也可以把该目录登记为本地插件。
## 首次使用
1. 先打开 Codex App,并确认目标任务正常存在。
2. 按上面的命令启动工作台并打开本机地址。
-3. macOS 首次询问辅助功能权限时允许 Codex,然后重新启动工作台。
+3. macOS 首次询问辅助功能权限时允许 Codex;Windows 确认 App 与 PowerShell 权限等级相同。
4. 如需语音输入,在浏览器地址栏或系统设置中允许麦克风权限。
5. 在任意卡片底部输入消息并发送;网页会显示发送状态并同步 App 中同一 Session 的后续回复。
## 当前限制
-- 当前重点测试平台是 macOS;原生单次发送依赖 Accessibility API,其他系统尚不支持这一发送方式。
+- macOS 主流程已实机验证;Windows UI Automation 桥已实现并加入 CI 编译探针,但仍需要真实 Codex 窗口验证输入框和发送按钮识别。
- Codex app-server 接口属于实验能力,Codex 更新后可能需要跟随调整。
- 网页的模型、速度和推理强度控件目前可以完整显示并保存选择,但原生单次发送仍以 Codex App 当前输入框的设置为准。需要精确控制时,请先在 App 中调整;网页直控会在后续版本完善。
- Session 更新采用高频轮询,体验接近实时,但并非严格的推送协议。
- 浏览器语音识别能力和稳定性由 Web Speech API、浏览器及网络环境决定。
- 原生桥接会短暂定位目标任务并恢复原窗口,恢复过程属于尽力而为。
+- Windows 原生模式与 WSL 默认使用不同的 Codex 主目录;混用时要确保工作台和 App 指向同一个 `CODEX_HOME`。
## 安全与隐私
@@ -100,8 +135,11 @@ CODEX_SESSION_GRID_PORT=4318 bash scripts/stop.sh
├── public/ # 宫格 Web UI
├── scripts/server.mjs # localhost Sidecar 与 Session 同步
├── scripts/native-bridge.swift # macOS 原生单次发送桥接
-├── scripts/start.sh # 启动脚本
-├── scripts/stop.sh # 停止脚本
+├── scripts/native-bridge-windows.cs # Windows UI Automation 桥接
+├── scripts/native-bridge-windows.ps1 # Windows 桥接加载器
+├── scripts/start.sh / stop.sh # macOS 启停脚本
+├── scripts/start.ps1 / stop.ps1 # Windows 启停脚本
+├── scripts/diagnose-windows.ps1 # Windows 脱敏诊断
└── skills/codex-session-grid/ # Codex 技能与引导配置
```
@@ -114,6 +152,8 @@ sh -n scripts/start.sh scripts/stop.sh
swiftc -typecheck scripts/native-bridge.swift
```
+Windows 的 PowerShell 语法、C# UI Automation 编译和无 App 探针由 `.github/workflows/cross-platform.yml` 在 `windows-latest` 上检查。真实发送仍必须在交互式 Windows 桌面验证。
+
发布记录见 [CHANGELOG.md](CHANGELOG.md),参与开发前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
## 路线图
@@ -121,7 +161,7 @@ swiftc -typecheck scripts/native-bridge.swift
- 让网页模型、速度和推理强度直接、安全地控制原生发送设置。
- 降低对实验性 app-server 协议的耦合。
- 增强语音识别错误诊断和浏览器兼容提示。
-- 完善安装器、自动更新和更多 macOS 版本测试。
+- 完成 Windows 11 实机矩阵、安装器和自动更新。
## 作者
diff --git a/SECURITY.md b/SECURITY.md
index 7a2b7be..588a43a 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -12,4 +12,4 @@
## 本地边界
-Codex Session Grid 设计为仅监听 `127.0.0.1` 的本机工具。不要将服务绑定到 `0.0.0.0`、局域网或公网,也不要通过反向代理公开。原生发送依赖 macOS Accessibility 权限;只应向可信的 Codex 安装授予该权限。
+Codex Session Grid 设计为仅监听 `127.0.0.1` 的本机工具。不要将服务绑定到 `0.0.0.0`、局域网或公网,也不要通过反向代理公开。macOS 原生发送依赖 Accessibility 权限;Windows 原生发送依赖 UI Automation 和进程权限等级。只应向可信的 Codex 安装授予相关权限,不要为了发送功能长期以管理员身份运行整个工作流。
diff --git a/docs/WINDOWS_TESTING.md b/docs/WINDOWS_TESTING.md
new file mode 100644
index 0000000..30fa0ae
--- /dev/null
+++ b/docs/WINDOWS_TESTING.md
@@ -0,0 +1,86 @@
+# Windows 11 验证指南
+
+Windows 兼容层已经实现,但 Codex 输入框和发送按钮必须在真实的交互式 Windows 桌面验证。请不要只在 WSL 或无桌面的 CI 环境判断发送功能是否可用。
+
+## 1. 准备环境
+
+- Windows 11。
+- 已登录并能正常打开任务的 Windows Codex/ChatGPT Desktop App。
+- Node.js LTS 与可用的 `codex` 命令。
+- Windows PowerShell 5.1。
+- Edge 或 Chrome(如需测试语音)。
+
+如果 Codex App 以管理员身份运行,请同时以管理员身份打开 PowerShell;否则两端都使用普通权限。
+
+Windows 兼容层尚未合并到 `main` 时,请检出测试分支:
+
+```powershell
+git fetch origin
+git switch codex/windows-cross-platform
+```
+
+## 2. 启动
+
+在项目目录运行:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/start.ps1
+```
+
+浏览器应打开 [http://127.0.0.1:4317](http://127.0.0.1:4317),顶部显示“Win 原生单次发送”。
+
+## 3. 运行脱敏诊断
+
+保持 Codex App 打开,并进入一个可发送消息的任务,然后运行:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/diagnose-windows.ps1
+```
+
+重点检查:
+
+- `health.ok` 为 `true`。
+- `health.platform` 为 `windows`。
+- `nativeBridge.ok`、`supported`、`trusted` 为 `true`。
+- `nativeBridge.appRunning` 为 `true`。
+- `nativeBridge.composerFound` 为 `true`。
+
+诊断只输出工具版本、进程名、元素数量和输入框元数据,不应包含 Session 消息或待发送内容。反馈前仍请删除本机用户名和路径。
+
+## 4. 单次发送验证
+
+1. 在 Codex App 中打开一个专门用于测试的任务。
+2. 在网页对应卡片输入:`Windows 单次同步测试:你好`。
+3. 只点击一次发送。
+4. 确认 Codex App 中只出现一条用户消息。
+5. 确认网页立即显示“已发送 · 等待回复”。
+6. 等 Codex 回复后,确认 App 和网页内容一致。
+7. 确认没有出现第二个 Turn,也没有两份回复。
+
+再分别测试:
+
+- 中文、英文、Emoji。
+- 包含一行换行的消息。
+- 让 Codex 读取一个文件,检查工具状态与绿色运行点。
+- 在 Edge/Chrome 中授权麦克风并测试语音输入。
+- 四宫格与九宫格拖拽、折叠和控制区显示。
+
+## 5. 常见问题
+
+### 显示“Windows 原生桥待连接”
+
+先打开 Codex App 和一个任务,再运行诊断脚本。若 `trusted` 为 `false`,通常是 PowerShell 与 App 权限等级不同。
+
+### `appRunning=true`,但 `composerFound=false`
+
+说明已经找到桌面 App,但当前版本的输入框 UI Automation 属性与候选规则不匹配。请提供脱敏后的 `nativeBridge.candidates`、`candidateCount` 和界面截图;不要发送对话内容。候选诊断不会主动输出控件文字,但仍请在反馈前检查一遍。
+
+### 使用 WSL 后任务列表不同
+
+Windows App 默认使用 `%USERPROFILE%\.codex`,WSL 默认使用 Linux 的 `~/.codex`。优先用 Windows 原生 Codex 运行本工作台;如果必须混用,请让两边指向同一个 `CODEX_HOME`,并做好目录备份。
+
+## 6. 停止
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/stop.ps1
+```
diff --git a/public/app.js b/public/app.js
index 24a2031..cebcf75 100644
--- a/public/app.js
+++ b/public/app.js
@@ -56,6 +56,7 @@ const state = {
activeRecognition: null,
draggedPanel: null,
nativeReady: false,
+ nativePlatform: "",
pendingMessages: new Map(),
};
@@ -71,7 +72,8 @@ function api(path, options) {
}
function updateConnectionLabel() {
- connectionLabel.textContent = `${state.threads.length} 个任务 · 实时同步${state.nativeReady ? " · 原生单次发送" : ""}`;
+ const platform = state.nativePlatform === "windows" ? "Win" : state.nativePlatform === "macos" ? "Mac" : "";
+ connectionLabel.textContent = `${state.threads.length} 个任务 · 实时同步${state.nativeReady ? ` · ${platform ? `${platform} ` : ""}原生单次发送` : ""}`;
connectionLabel.className = state.nativeReady ? "connected" : "error";
}
@@ -81,7 +83,9 @@ function threadTitle(thread) {
function shortPath(path) {
if (!path) return "未知目录";
- const home = path.match(/^\/Users\/[^/]+/i)?.[0] || path.match(/^\/home\/[^/]+/i)?.[0];
+ const home = path.match(/^\/Users\/[^/]+/i)?.[0]
+ || path.match(/^\/home\/[^/]+/i)?.[0]
+ || path.match(/^[a-z]:\\Users\\[^\\]+/i)?.[0];
return home ? path.replace(home, "~") : path;
}
@@ -1151,12 +1155,19 @@ async function boot() {
api("/api/native-bridge/status").catch((error) => ({ ok: false, error: error.message })),
]);
setLayout(state.layout);
+ state.nativePlatform = nativeStatus?.platform || "";
if (nativeStatus?.trusted) {
state.nativeReady = true;
updateConnectionLabel();
} else {
state.nativeReady = false;
- connectionLabel.textContent = `${state.threads.length} 个任务 · 实时同步 · 原生发送待授权`;
+ const statusLabel = nativeStatus?.supported === false
+ ? "当前系统仅支持查看"
+ : state.nativePlatform === "windows"
+ ? "Windows 原生桥待连接"
+ : "原生发送待授权";
+ connectionLabel.textContent = `${state.threads.length} 个任务 · 实时同步 · ${statusLabel}`;
+ connectionLabel.title = nativeStatus?.error || "";
connectionLabel.className = "error";
}
} catch (error) {
diff --git a/public/index.html b/public/index.html
index 59b8860..f2b4297 100644
--- a/public/index.html
+++ b/public/index.html
@@ -72,7 +72,7 @@
开始使用多任务宫格
3
-
后台原生发送网页发送会在后台定位 Codex App 的目标任务,通过原生输入框只提交一次;网页立即显示“已发送”并继续同步同一 Session 的回复,不需要你手动切换 App。首次使用请在“系统设置 → 隐私与安全性 → 辅助功能”中允许 Codex 控制电脑;原生发送以 App 当前模型、速度和推理强度为准。
+
跨平台原生发送网页会定位 Codex App 的目标任务,通过原生输入框只提交一次,再同步同一 Session 的回复。macOS 首次使用请在“系统设置 → 隐私与安全性 → 辅助功能”中允许 Codex;Windows 请使用 PowerShell 启动,并让工作台与 Codex App 保持相同权限级别。原生发送以 App 当前模型、速度和推理强度为准。
4
diff --git a/scripts/diagnose-windows.ps1 b/scripts/diagnose-windows.ps1
new file mode 100644
index 0000000..4dbe1b0
--- /dev/null
+++ b/scripts/diagnose-windows.ps1
@@ -0,0 +1,46 @@
+[CmdletBinding()]
+param([int]$Port = 0)
+
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
+[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
+
+if ($Port -eq 0) {
+ $parsedPort = 0
+ if ($env:CODEX_SESSION_GRID_PORT -and [int]::TryParse($env:CODEX_SESSION_GRID_PORT, [ref]$parsedPort)) {
+ $Port = $parsedPort
+ }
+ else {
+ $Port = 4317
+ }
+}
+
+$report = [ordered]@{
+ ok = $true
+ platform = "windows"
+ powershell = $PSVersionTable.PSVersion.ToString()
+ node = $null
+ codex = $null
+ health = $null
+ nativeBridge = $null
+}
+
+try { $report.node = (& node --version 2>&1 | Select-Object -First 1) } catch { $report.node = $_.Exception.Message }
+try { $report.codex = (& codex --version 2>&1 | Select-Object -First 1) } catch { $report.codex = $_.Exception.Message }
+try {
+ $report.health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 2
+}
+catch {
+ $report.health = [ordered]@{ ok = $false; error = $_.Exception.Message }
+}
+
+$bridgeScript = Join-Path $PSScriptRoot "native-bridge-windows.ps1"
+try {
+ $bridgeOutput = & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $bridgeScript -Action probe
+ $report.nativeBridge = $bridgeOutput | ConvertFrom-Json
+}
+catch {
+ $report.nativeBridge = [ordered]@{ ok = $false; error = $_.Exception.Message }
+}
+
+$report | ConvertTo-Json -Depth 8
diff --git a/scripts/native-bridge-windows.cs b/scripts/native-bridge-windows.cs
new file mode 100644
index 0000000..cbd1e64
--- /dev/null
+++ b/scripts/native-bridge-windows.cs
@@ -0,0 +1,587 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Windows;
+using System.Windows.Automation;
+
+namespace CodexSessionGrid
+{
+ public static class WindowsAutomationBridge
+ {
+ private const uint InputKeyboard = 1;
+ private const uint KeyEventKeyUp = 0x0002;
+ private const uint KeyEventUnicode = 0x0004;
+ private const ushort VkBack = 0x08;
+ private const ushort VkReturn = 0x0D;
+ private const ushort VkShift = 0x10;
+ private const ushort VkControl = 0x11;
+ private const ushort VkA = 0x41;
+ private const int RestoreWindow = 9;
+
+ private sealed class ComposerCandidate
+ {
+ public AutomationElement Element;
+ public int Score;
+ public string Label;
+ public Rect Frame;
+ }
+
+ private sealed class Target
+ {
+ public Process Process;
+ public AutomationElement Root;
+ public ComposerCandidate Composer;
+ public int CandidateCount;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MouseInput
+ {
+ public int Dx;
+ public int Dy;
+ public uint MouseData;
+ public uint Flags;
+ public uint Time;
+ public UIntPtr ExtraInfo;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct KeyboardInput
+ {
+ public ushort VirtualKey;
+ public ushort ScanCode;
+ public uint Flags;
+ public uint Time;
+ public UIntPtr ExtraInfo;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct HardwareInput
+ {
+ public uint Message;
+ public ushort ParameterLow;
+ public ushort ParameterHigh;
+ }
+
+ [StructLayout(LayoutKind.Explicit)]
+ private struct InputUnion
+ {
+ [FieldOffset(0)] public MouseInput Mouse;
+ [FieldOffset(0)] public KeyboardInput Keyboard;
+ [FieldOffset(0)] public HardwareInput Hardware;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Input
+ {
+ public uint Type;
+ public InputUnion Data;
+ }
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetForegroundWindow();
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetForegroundWindow(IntPtr window);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ShowWindow(IntPtr window, int command);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern uint SendInput(uint count, Input[] inputs, int size);
+
+ public static IDictionary
Probe()
+ {
+ Dictionary result = BaseResult();
+ List applications = FindApplicationProcesses();
+ result["appRunning"] = applications.Count > 0;
+ if (applications.Count == 0)
+ {
+ result["composerFound"] = false;
+ result["trusted"] = true;
+ return result;
+ }
+
+ Target target = FindBestTarget(applications);
+ result["composerFound"] = target != null && target.Composer != null;
+ result["trusted"] = target != null;
+ if (target == null)
+ {
+ result["error"] = "检测到 Codex/ChatGPT,但无法读取窗口。请确认工作台和桌面 App 使用相同权限级别。";
+ return result;
+ }
+
+ result["processName"] = SafeProcessName(target.Process);
+ result["candidateCount"] = target.CandidateCount;
+ if (target.Composer != null) result["composer"] = DescribeComposer(target.Composer);
+ else result["candidates"] = DescribeEditableCandidates(target.Root);
+ return result;
+ }
+
+ public static IDictionary Send(string threadId, string message)
+ {
+ Guid parsed;
+ if (!Guid.TryParse(threadId, out parsed)) throw new ArgumentException("Windows 原生桥收到的任务 ID 无效");
+ if (String.IsNullOrWhiteSpace(message)) throw new ArgumentException("发送内容不能为空");
+
+ IntPtr previousForeground = GetForegroundWindow();
+ OpenThread(threadId);
+
+ Target target = null;
+ for (int attempt = 0; attempt < 80; attempt++)
+ {
+ target = FindBestTarget(FindApplicationProcesses());
+ if (target != null && target.Composer != null) break;
+ Thread.Sleep(250);
+ }
+
+ if (target == null || target.Composer == null)
+ {
+ RestoreForeground(previousForeground);
+ throw new InvalidOperationException(
+ "无法识别 Windows Codex 输入框。请先打开 Codex 任务,并确保工作台与 Codex App 使用相同权限级别。"
+ );
+ }
+
+ string inputMethod = SetComposerValue(target, message);
+ Thread.Sleep(120);
+
+ string submissionMethod = InvokeSendButton(target);
+ if (submissionMethod == null)
+ {
+ FocusTarget(target);
+ PressVirtualKey(VkReturn);
+ submissionMethod = "keyboard-enter";
+ }
+
+ Thread.Sleep(160);
+ bool restored = RestoreForeground(previousForeground);
+ Dictionary result = BaseResult();
+ result["native"] = true;
+ result["threadId"] = threadId;
+ result["submitted"] = true;
+ result["inputMethod"] = inputMethod;
+ result["submissionMethod"] = submissionMethod;
+ result["foregroundRestored"] = restored;
+ result["processName"] = SafeProcessName(target.Process);
+ return result;
+ }
+
+ private static Dictionary BaseResult()
+ {
+ Dictionary result = new Dictionary();
+ result["ok"] = true;
+ result["supported"] = true;
+ result["platform"] = "windows";
+ result["permissionMode"] = "windows-uia";
+ return result;
+ }
+
+ private static IDictionary DescribeComposer(ComposerCandidate composer)
+ {
+ Dictionary description = new Dictionary();
+ description["label"] = composer.Label;
+ description["score"] = composer.Score;
+ description["x"] = composer.Frame.IsEmpty ? 0 : (int)composer.Frame.Left;
+ description["y"] = composer.Frame.IsEmpty ? 0 : (int)composer.Frame.Top;
+ description["width"] = composer.Frame.IsEmpty ? 0 : (int)composer.Frame.Width;
+ description["height"] = composer.Frame.IsEmpty ? 0 : (int)composer.Frame.Height;
+ description["controlType"] = SafeString(composer.Element, AutomationElement.ControlTypeProperty);
+ description["automationId"] = SafeString(composer.Element, AutomationElement.AutomationIdProperty);
+ description["className"] = SafeString(composer.Element, AutomationElement.ClassNameProperty);
+ return description;
+ }
+
+ private static IList> DescribeEditableCandidates(AutomationElement root)
+ {
+ List> descriptions = new List>();
+ Condition controls = new OrCondition(
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit),
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Document)
+ );
+ AutomationElementCollection elements = root.FindAll(TreeScope.Descendants, controls);
+ int count = Math.Min(20, elements.Count);
+ for (int index = 0; index < count; index++)
+ {
+ AutomationElement element = elements[index];
+ Rect frame = SafeRect(element);
+ Dictionary item = new Dictionary();
+ item["controlType"] = SafeString(element, AutomationElement.ControlTypeProperty);
+ item["automationId"] = SafeString(element, AutomationElement.AutomationIdProperty);
+ item["className"] = SafeString(element, AutomationElement.ClassNameProperty);
+ item["enabled"] = SafeBool(element, AutomationElement.IsEnabledProperty);
+ item["focusable"] = SafeBool(element, AutomationElement.IsKeyboardFocusableProperty);
+ item["valuePattern"] = SupportsPattern(element, ValuePattern.Pattern);
+ item["x"] = frame.IsEmpty ? 0 : (int)frame.Left;
+ item["y"] = frame.IsEmpty ? 0 : (int)frame.Top;
+ item["width"] = frame.IsEmpty ? 0 : (int)frame.Width;
+ item["height"] = frame.IsEmpty ? 0 : (int)frame.Height;
+ descriptions.Add(item);
+ }
+ return descriptions;
+ }
+
+ private static List FindApplicationProcesses()
+ {
+ List candidates = new List();
+ foreach (Process process in Process.GetProcesses())
+ {
+ try
+ {
+ if (process.MainWindowHandle == IntPtr.Zero) continue;
+ string name = process.ProcessName.ToLowerInvariant();
+ string title = (process.MainWindowTitle ?? String.Empty).ToLowerInvariant();
+ bool direct = name.Contains("codex") || name.Contains("chatgpt") || name.Contains("openai");
+ bool packaged = name == "applicationframehost" && (title.Contains("codex") || title.Contains("chatgpt"));
+ if (direct || packaged) candidates.Add(process);
+ }
+ catch
+ {
+ process.Dispose();
+ }
+ }
+ candidates.Sort(delegate(Process left, Process right)
+ {
+ return ApplicationScore(right).CompareTo(ApplicationScore(left));
+ });
+ return candidates;
+ }
+
+ private static int ApplicationScore(Process process)
+ {
+ try
+ {
+ string name = process.ProcessName.ToLowerInvariant();
+ string title = (process.MainWindowTitle ?? String.Empty).ToLowerInvariant();
+ int score = 0;
+ if (name == "codex" || name == "chatgpt") score += 120;
+ if (name.Contains("codex")) score += 80;
+ if (name.Contains("chatgpt")) score += 60;
+ if (name.Contains("openai")) score += 35;
+ if (title.Contains("codex")) score += 45;
+ if (title.Contains("chatgpt")) score += 25;
+ return score;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ private static Target FindBestTarget(List applications)
+ {
+ Target best = null;
+ foreach (Process process in applications)
+ {
+ try
+ {
+ AutomationElement root = AutomationElement.FromHandle(process.MainWindowHandle);
+ if (root == null) continue;
+ int candidateCount;
+ ComposerCandidate composer = FindComposer(root, out candidateCount);
+ Target current = new Target();
+ current.Process = process;
+ current.Root = root;
+ current.Composer = composer;
+ current.CandidateCount = candidateCount;
+ if (best == null || (composer != null && (best.Composer == null || composer.Score > best.Composer.Score)))
+ {
+ best = current;
+ }
+ }
+ catch (ElementNotAvailableException) {}
+ catch (UnauthorizedAccessException) {}
+ catch (InvalidOperationException) {}
+ }
+ return best;
+ }
+
+ private static ComposerCandidate FindComposer(AutomationElement root, out int candidateCount)
+ {
+ Condition controls = new OrCondition(
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit),
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Document)
+ );
+ AutomationElementCollection elements = root.FindAll(TreeScope.Descendants, controls);
+ candidateCount = elements.Count;
+ Rect windowFrame = SafeRect(root);
+ ComposerCandidate best = null;
+ foreach (AutomationElement element in elements)
+ {
+ int score = ComposerScore(element, windowFrame);
+ if (score < 1) continue;
+ if (best == null || score > best.Score)
+ {
+ best = new ComposerCandidate();
+ best.Element = element;
+ best.Score = score;
+ best.Label = NormalizedLabel(element);
+ best.Frame = SafeRect(element);
+ }
+ }
+ return best;
+ }
+
+ private static int ComposerScore(AutomationElement element, Rect windowFrame)
+ {
+ string name = SafeString(element, AutomationElement.NameProperty).ToLowerInvariant();
+ string help = SafeString(element, AutomationElement.HelpTextProperty).ToLowerInvariant();
+ string automationId = SafeString(element, AutomationElement.AutomationIdProperty).ToLowerInvariant();
+ string className = SafeString(element, AutomationElement.ClassNameProperty).ToLowerInvariant();
+ string label = name + " " + help + " " + automationId + " " + className;
+ if (ContainsAny(label, "search", "filter", "find", "筛选", "搜索")) return -200;
+ if (SafeBool(element, AutomationElement.IsPasswordProperty)) return -200;
+
+ int score = 0;
+ ControlType type = SafeControlType(element);
+ if (type == ControlType.Edit) score += 35;
+ if (type == ControlType.Document) score += 8;
+ if (SafeBool(element, AutomationElement.IsEnabledProperty)) score += 15;
+ if (SafeBool(element, AutomationElement.IsKeyboardFocusableProperty)) score += 12;
+ if (ContainsAny(label, "composer", "prompt", "textarea")) score += 70;
+ if (ContainsAny(label, "send a message", "message codex", "ask codex", "发送消息", "向这个任务发送消息", "输入消息")) score += 80;
+ if (SupportsPattern(element, ValuePattern.Pattern)) score += 30;
+
+ Rect frame = SafeRect(element);
+ if (!frame.IsEmpty)
+ {
+ if (frame.Width >= 220) score += 18;
+ if (frame.Height >= 24 && frame.Height <= 360) score += 12;
+ if (!windowFrame.IsEmpty && frame.Top >= windowFrame.Top + windowFrame.Height * 0.5) score += 35;
+ }
+ return score;
+ }
+
+ private static string SetComposerValue(Target target, string message)
+ {
+ object pattern;
+ if (target.Composer.Element.TryGetCurrentPattern(ValuePattern.Pattern, out pattern))
+ {
+ ValuePattern value = pattern as ValuePattern;
+ if (value != null && !value.Current.IsReadOnly)
+ {
+ value.SetValue(message);
+ return "value-pattern";
+ }
+ }
+
+ FocusTarget(target);
+ PressChord(VkControl, VkA);
+ PressVirtualKey(VkBack);
+ TypeUnicode(message);
+ return "keyboard-unicode";
+ }
+
+ private static string InvokeSendButton(Target target)
+ {
+ Condition buttonsOnly = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button);
+ AutomationElementCollection buttons = target.Root.FindAll(TreeScope.Descendants, buttonsOnly);
+ AutomationElement best = null;
+ int bestScore = Int32.MinValue;
+ Rect composerFrame = target.Composer.Frame;
+ foreach (AutomationElement button in buttons)
+ {
+ if (!SafeBool(button, AutomationElement.IsEnabledProperty)) continue;
+ string label = NormalizedLabel(button).ToLowerInvariant();
+ if (!ContainsAny(label, "send", "submit", "发送", "提交")) continue;
+ int score = 100;
+ Rect frame = SafeRect(button);
+ if (!frame.IsEmpty && !composerFrame.IsEmpty)
+ {
+ double dx = frame.Left + frame.Width / 2 - (composerFrame.Left + composerFrame.Width / 2);
+ double dy = frame.Top + frame.Height / 2 - (composerFrame.Top + composerFrame.Height / 2);
+ score -= (int)Math.Min(80, Math.Sqrt(dx * dx + dy * dy) / 20);
+ }
+ if (score > bestScore)
+ {
+ best = button;
+ bestScore = score;
+ }
+ }
+
+ if (best == null) return null;
+ object pattern;
+ if (!best.TryGetCurrentPattern(InvokePattern.Pattern, out pattern)) return null;
+ InvokePattern invoke = pattern as InvokePattern;
+ if (invoke == null) return null;
+ invoke.Invoke();
+ return "uia-invoke";
+ }
+
+ private static void OpenThread(string threadId)
+ {
+ ProcessStartInfo start = new ProcessStartInfo();
+ start.FileName = "codex://threads/" + threadId;
+ start.UseShellExecute = true;
+ try
+ {
+ Process process = Process.Start(start);
+ if (process != null) process.Dispose();
+ }
+ catch (Exception error)
+ {
+ throw new InvalidOperationException("无法通过 codex:// 链接打开目标任务:" + error.Message, error);
+ }
+ }
+
+ private static void FocusTarget(Target target)
+ {
+ IntPtr window = target.Process.MainWindowHandle;
+ if (window != IntPtr.Zero)
+ {
+ ShowWindow(window, RestoreWindow);
+ SetForegroundWindow(window);
+ }
+ target.Composer.Element.SetFocus();
+ Thread.Sleep(80);
+ }
+
+ private static bool RestoreForeground(IntPtr previous)
+ {
+ if (previous == IntPtr.Zero) return false;
+ ShowWindow(previous, RestoreWindow);
+ SetForegroundWindow(previous);
+ Thread.Sleep(80);
+ return GetForegroundWindow() == previous;
+ }
+
+ private static void TypeUnicode(string value)
+ {
+ List inputs = new List();
+ foreach (char character in value)
+ {
+ if (character == '\r') continue;
+ if (character == '\n')
+ {
+ FlushInputs(inputs);
+ PressChord(VkShift, VkReturn);
+ continue;
+ }
+ inputs.Add(UnicodeInput(character, false));
+ inputs.Add(UnicodeInput(character, true));
+ if (inputs.Count >= 256) FlushInputs(inputs);
+ }
+ FlushInputs(inputs);
+ }
+
+ private static Input UnicodeInput(char character, bool keyUp)
+ {
+ Input input = new Input();
+ input.Type = InputKeyboard;
+ input.Data.Keyboard = new KeyboardInput();
+ input.Data.Keyboard.ScanCode = character;
+ input.Data.Keyboard.Flags = KeyEventUnicode | (keyUp ? KeyEventKeyUp : 0);
+ return input;
+ }
+
+ private static Input VirtualKeyInput(ushort key, bool keyUp)
+ {
+ Input input = new Input();
+ input.Type = InputKeyboard;
+ input.Data.Keyboard = new KeyboardInput();
+ input.Data.Keyboard.VirtualKey = key;
+ input.Data.Keyboard.Flags = keyUp ? KeyEventKeyUp : 0;
+ return input;
+ }
+
+ private static void PressVirtualKey(ushort key)
+ {
+ SendInputs(new Input[] { VirtualKeyInput(key, false), VirtualKeyInput(key, true) });
+ }
+
+ private static void PressChord(ushort modifier, ushort key)
+ {
+ SendInputs(new Input[]
+ {
+ VirtualKeyInput(modifier, false),
+ VirtualKeyInput(key, false),
+ VirtualKeyInput(key, true),
+ VirtualKeyInput(modifier, true),
+ });
+ }
+
+ private static void FlushInputs(List inputs)
+ {
+ if (inputs.Count == 0) return;
+ SendInputs(inputs.ToArray());
+ inputs.Clear();
+ }
+
+ private static void SendInputs(Input[] inputs)
+ {
+ uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));
+ if (sent != (uint)inputs.Length) throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows 无法向 Codex 输入文字");
+ }
+
+ private static string NormalizedLabel(AutomationElement element)
+ {
+ return String.Join(" ", new string[]
+ {
+ SafeString(element, AutomationElement.NameProperty),
+ SafeString(element, AutomationElement.HelpTextProperty),
+ SafeString(element, AutomationElement.AutomationIdProperty),
+ SafeString(element, AutomationElement.ClassNameProperty),
+ }).Trim();
+ }
+
+ private static bool ContainsAny(string text, params string[] values)
+ {
+ foreach (string value in values)
+ {
+ if (text.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0) return true;
+ }
+ return false;
+ }
+
+ private static bool SupportsPattern(AutomationElement element, AutomationPattern pattern)
+ {
+ object value;
+ try { return element.TryGetCurrentPattern(pattern, out value); }
+ catch { return false; }
+ }
+
+ private static string SafeString(AutomationElement element, AutomationProperty property)
+ {
+ try
+ {
+ object value = element.GetCurrentPropertyValue(property, true);
+ if (value == null || value == AutomationElement.NotSupported) return String.Empty;
+ return Convert.ToString(value) ?? String.Empty;
+ }
+ catch { return String.Empty; }
+ }
+
+ private static bool SafeBool(AutomationElement element, AutomationProperty property)
+ {
+ try
+ {
+ object value = element.GetCurrentPropertyValue(property, true);
+ return value is bool && (bool)value;
+ }
+ catch { return false; }
+ }
+
+ private static Rect SafeRect(AutomationElement element)
+ {
+ try { return element.Current.BoundingRectangle; }
+ catch { return Rect.Empty; }
+ }
+
+ private static ControlType SafeControlType(AutomationElement element)
+ {
+ try { return element.Current.ControlType; }
+ catch { return null; }
+ }
+
+ private static string SafeProcessName(Process process)
+ {
+ try { return process.ProcessName; }
+ catch { return "unknown"; }
+ }
+ }
+}
diff --git a/scripts/native-bridge-windows.ps1 b/scripts/native-bridge-windows.ps1
new file mode 100644
index 0000000..0f0f723
--- /dev/null
+++ b/scripts/native-bridge-windows.ps1
@@ -0,0 +1,51 @@
+param(
+ [ValidateSet("probe", "send")]
+ [string]$Action = "probe",
+ [string]$ThreadId = ""
+)
+
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
+[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
+[Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
+
+function Write-BridgeJson {
+ param([object]$Value)
+ [Console]::Out.WriteLine(($Value | ConvertTo-Json -Depth 8 -Compress))
+}
+
+try {
+ if ($env:OS -ne "Windows_NT") {
+ throw "Windows UI Automation 桥只能在 Windows 上运行"
+ }
+
+ Add-Type -AssemblyName WindowsBase
+ Add-Type -AssemblyName UIAutomationClient
+ Add-Type -AssemblyName UIAutomationTypes
+
+ $sourcePath = Join-Path $PSScriptRoot "native-bridge-windows.cs"
+ $references = @(
+ [System.Windows.Rect].Assembly.Location,
+ [System.Windows.Automation.AutomationElement].Assembly.Location,
+ [System.Windows.Automation.AutomationIdentifier].Assembly.Location
+ ) | Select-Object -Unique
+ Add-Type -Path $sourcePath -ReferencedAssemblies $references
+
+ if ($Action -eq "probe") {
+ Write-BridgeJson ([CodexSessionGrid.WindowsAutomationBridge]::Probe())
+ exit 0
+ }
+
+ $message = [Console]::In.ReadToEnd()
+ Write-BridgeJson ([CodexSessionGrid.WindowsAutomationBridge]::Send($ThreadId, $message))
+ exit 0
+}
+catch {
+ Write-BridgeJson ([ordered]@{
+ ok = $false
+ platform = "windows"
+ supported = $true
+ error = $_.Exception.Message
+ })
+ exit 0
+}
diff --git a/scripts/server.mjs b/scripts/server.mjs
index a3ab933..8aab26f 100755
--- a/scripts/server.mjs
+++ b/scripts/server.mjs
@@ -14,8 +14,11 @@ const args = process.argv.slice(2);
const portArg = args.indexOf("--port");
const port = Number(portArg >= 0 ? args[portArg + 1] : process.env.CODEX_SESSION_GRID_PORT || 4317);
const host = "127.0.0.1";
-const nativeBridgeSource = join(scriptDir, "native-bridge.swift");
-const nativeBridgeBinary = join(tmpdir(), `codex-session-grid-native-bridge-${process.getuid?.() ?? "user"}`);
+const appVersion = "0.2.0-beta.1";
+const macBridgeSource = join(scriptDir, "native-bridge.swift");
+const macBridgeBinary = join(tmpdir(), `codex-session-grid-native-bridge-${process.getuid?.() ?? "user"}`);
+const windowsBridgeScript = join(scriptDir, "native-bridge-windows.ps1");
+const windowsBridgeSource = join(scriptDir, "native-bridge-windows.cs");
if (!Number.isInteger(port) || port < 1 || port > 65535) {
console.error("Invalid port. Use --port 4317 or set CODEX_SESSION_GRID_PORT.");
@@ -30,9 +33,9 @@ const mimeTypes = {
".svg": "image/svg+xml",
};
-function captureProcess(command, commandArgs, timeoutMs = 30_000) {
+function captureProcess(command, commandArgs, timeoutMs = 30_000, input = "") {
return new Promise((resolve, reject) => {
- const child = spawn(command, commandArgs, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
+ const child = spawn(command, commandArgs, { stdio: ["pipe", "pipe", "pipe"], env: process.env });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
@@ -53,6 +56,8 @@ function captureProcess(command, commandArgs, timeoutMs = 30_000) {
if (code === 0) resolve({ stdout, stderr });
else reject(new Error(stderr.trim() || stdout.trim() || `Native Codex bridge exited with ${code}`));
});
+ child.stdin.on("error", () => {});
+ child.stdin.end(input);
});
}
@@ -61,39 +66,86 @@ class NativeCodexBridge {
this.buildPromise = null;
}
+ get platform() {
+ if (process.platform === "darwin") return "macos";
+ if (process.platform === "win32") return "windows";
+ return process.platform;
+ }
+
+ get supported() {
+ return process.platform === "darwin" || process.platform === "win32";
+ }
+
async ensureBuilt() {
- if (process.platform !== "darwin") throw new Error("原生同步发送目前仅支持 macOS Codex App");
+ if (process.platform === "win32") {
+ await Promise.all([stat(windowsBridgeScript), stat(windowsBridgeSource)]);
+ return;
+ }
+ if (process.platform !== "darwin") throw new Error(`原生同步发送暂不支持 ${process.platform}`);
let rebuild = true;
try {
- const [sourceInfo, binaryInfo] = await Promise.all([stat(nativeBridgeSource), stat(nativeBridgeBinary)]);
+ const [sourceInfo, binaryInfo] = await Promise.all([stat(macBridgeSource), stat(macBridgeBinary)]);
rebuild = binaryInfo.mtimeMs < sourceInfo.mtimeMs;
} catch {}
if (!rebuild) return;
if (!this.buildPromise) {
- this.buildPromise = captureProcess("/usr/bin/swiftc", ["-O", "-o", nativeBridgeBinary, nativeBridgeSource], 60_000)
+ this.buildPromise = captureProcess("/usr/bin/swiftc", ["-O", "-o", macBridgeBinary, macBridgeSource], 60_000)
.finally(() => { this.buildPromise = null; });
}
await this.buildPromise;
}
- async run(commandArgs) {
+ async run(commandArgs, input = "") {
await this.ensureBuilt();
- const { stdout } = await captureProcess(nativeBridgeBinary, commandArgs, 20_000);
+ const powershell = process.env.CODEX_SESSION_GRID_POWERSHELL || "powershell.exe";
+ const windowsArgs = commandArgs[0] === "send"
+ ? ["-Action", "send", "-ThreadId", commandArgs[1]]
+ : ["-Action", "probe"];
+ const invocation = process.platform === "win32"
+ ? {
+ command: powershell,
+ args: [
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ windowsBridgeScript,
+ ...windowsArgs,
+ ],
+ timeout: 30_000,
+ }
+ : { command: macBridgeBinary, args: commandArgs, timeout: 20_000 };
+ const { stdout } = await captureProcess(invocation.command, invocation.args, invocation.timeout, input);
let result;
try {
- result = JSON.parse(stdout);
+ result = JSON.parse(stdout.trim());
} catch {
throw new Error("原生同步桥返回了无法识别的结果");
}
if (!result.ok) throw new Error(result.error || "原生同步桥执行失败");
- return result;
+ return { ...result, platform: this.platform, supported: true };
}
probe() {
+ if (!this.supported) {
+ return Promise.resolve({
+ ok: true,
+ platform: this.platform,
+ supported: false,
+ trusted: false,
+ appRunning: false,
+ composerFound: false,
+ error: `原生同步发送暂不支持 ${process.platform}`,
+ });
+ }
return this.run(["probe"]);
}
send(threadId, text) {
+ if (!this.supported) throw new Error(`原生同步发送暂不支持 ${process.platform}`);
+ if (process.platform === "win32") return this.run(["send", threadId], text);
return this.run(["send", threadId, text]);
}
}
@@ -143,7 +195,7 @@ class CodexAppServer {
clientInfo: {
name: "codex-session-grid",
title: "Codex Session Grid",
- version: "0.1.0-beta.1",
+ version: appVersion,
},
capabilities: {
experimentalApi: true,
@@ -436,7 +488,7 @@ const server = createServer(async (request, response) => {
try {
if (request.method === "GET" && url.pathname === "/api/health") {
await bridge.ready;
- return sendJson(response, 200, { ok: true, version: "0.1.0-beta.1" });
+ return sendJson(response, 200, { ok: true, version: appVersion, platform: nativeBridge.platform });
}
if (request.method === "GET" && url.pathname === "/api/events") {
diff --git a/scripts/start.ps1 b/scripts/start.ps1
new file mode 100644
index 0000000..9593399
--- /dev/null
+++ b/scripts/start.ps1
@@ -0,0 +1,85 @@
+[CmdletBinding()]
+param(
+ [int]$Port = 0,
+ [switch]$NoOpen
+)
+
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
+
+if ($Port -eq 0) {
+ $parsedPort = 0
+ if ($env:CODEX_SESSION_GRID_PORT -and [int]::TryParse($env:CODEX_SESSION_GRID_PORT, [ref]$parsedPort)) {
+ $Port = $parsedPort
+ }
+ else {
+ $Port = 4317
+ }
+}
+if ($Port -lt 1 -or $Port -gt 65535) {
+ throw "端口必须在 1 到 65535 之间"
+}
+
+$userToken = if ($env:USERNAME) { $env:USERNAME } else { "user" }
+$runtimeDir = Join-Path ([IO.Path]::GetTempPath()) "codex-session-grid-$userToken-$Port"
+$pidFile = Join-Path $runtimeDir "server.pid"
+$stdoutLog = Join-Path $runtimeDir "server.out.log"
+$stderrLog = Join-Path $runtimeDir "server.err.log"
+$gridUrl = "http://127.0.0.1:$Port"
+$serverPath = (Resolve-Path (Join-Path $PSScriptRoot "server.mjs")).Path
+
+New-Item -ItemType Directory -Force -Path $runtimeDir | Out-Null
+
+function Test-GridHealth {
+ try {
+ $health = Invoke-RestMethod -Uri "$gridUrl/api/health" -TimeoutSec 1
+ return $health.ok -eq $true
+ }
+ catch {
+ return $false
+ }
+}
+
+if (Test-GridHealth) {
+ Write-Host "Codex Session Grid 已在运行:$gridUrl"
+}
+else {
+ $node = Get-Command node.exe -ErrorAction SilentlyContinue
+ if (-not $node) { $node = Get-Command node -ErrorAction Stop }
+
+ Remove-Item $stdoutLog, $stderrLog -Force -ErrorAction SilentlyContinue
+ $serverArguments = @("`"$serverPath`"", "--port", "$Port")
+ $startParameters = @{
+ FilePath = $node.Source
+ ArgumentList = $serverArguments
+ WindowStyle = "Hidden"
+ RedirectStandardOutput = $stdoutLog
+ RedirectStandardError = $stderrLog
+ PassThru = $true
+ }
+ $server = Start-Process @startParameters
+ Set-Content -Path $pidFile -Value $server.Id -Encoding ASCII
+
+ $started = $false
+ for ($attempt = 0; $attempt -lt 600; $attempt += 1) {
+ if (Test-GridHealth) {
+ $started = $true
+ break
+ }
+ if ($server.HasExited) {
+ $errorText = if (Test-Path $stderrLog) { Get-Content $stderrLog -Raw } else { "" }
+ throw "Codex Session Grid 启动失败。$errorText"
+ }
+ Start-Sleep -Milliseconds 100
+ }
+ if (-not $started) {
+ Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue
+ throw "Codex Session Grid 启动超时。日志:$stderrLog"
+ }
+ Write-Host "Codex Session Grid 已启动:$gridUrl"
+}
+
+$skipOpen = $NoOpen -or $env:CODEX_SESSION_GRID_NO_OPEN -eq "1"
+if (-not $skipOpen) {
+ Start-Process $gridUrl
+}
diff --git a/scripts/stop.ps1 b/scripts/stop.ps1
new file mode 100644
index 0000000..3034df6
--- /dev/null
+++ b/scripts/stop.ps1
@@ -0,0 +1,52 @@
+[CmdletBinding()]
+param([int]$Port = 0)
+
+$ErrorActionPreference = "Stop"
+
+if ($Port -eq 0) {
+ $parsedPort = 0
+ if ($env:CODEX_SESSION_GRID_PORT -and [int]::TryParse($env:CODEX_SESSION_GRID_PORT, [ref]$parsedPort)) {
+ $Port = $parsedPort
+ }
+ else {
+ $Port = 4317
+ }
+}
+if ($Port -lt 1 -or $Port -gt 65535) {
+ throw "端口必须在 1 到 65535 之间"
+}
+
+$userToken = if ($env:USERNAME) { $env:USERNAME } else { "user" }
+$runtimeDir = Join-Path ([IO.Path]::GetTempPath()) "codex-session-grid-$userToken-$Port"
+$pidFile = Join-Path $runtimeDir "server.pid"
+
+if (-not (Test-Path $pidFile)) {
+ Write-Host "端口 $Port 上没有由脚本记录的 Codex Session Grid。"
+ exit 0
+}
+
+$serverPid = 0
+$pidText = (Get-Content $pidFile -Raw).Trim()
+if (-not [int]::TryParse($pidText, [ref]$serverPid) -or $serverPid -lt 1) {
+ throw "PID 文件无效:$pidFile"
+}
+
+$process = Get-CimInstance Win32_Process -Filter "ProcessId = $serverPid" -ErrorAction SilentlyContinue
+if (-not $process) {
+ Remove-Item $pidFile -Force
+ Write-Host "Codex Session Grid 已经停止。"
+ exit 0
+}
+
+$expectedServer = (Resolve-Path (Join-Path $PSScriptRoot "server.mjs")).Path
+$commandLine = [string]$process.CommandLine
+if (-not $commandLine.Contains($expectedServer) -or -not $commandLine.Contains("--port")) {
+ throw "PID $serverPid 不是当前项目的 Codex Session Grid,已拒绝停止。"
+}
+
+& taskkill.exe /PID $serverPid /T /F | Out-Null
+if ($LASTEXITCODE -ne 0) {
+ throw "无法停止 Codex Session Grid(PID $serverPid)"
+}
+Remove-Item $pidFile -Force
+Write-Host "Codex Session Grid 已停止(PID $serverPid)。"
diff --git a/skills/codex-session-grid/SKILL.md b/skills/codex-session-grid/SKILL.md
index f5e0a05..fdbd307 100644
--- a/skills/codex-session-grid/SKILL.md
+++ b/skills/codex-session-grid/SKILL.md
@@ -1,11 +1,11 @@
---
name: codex-session-grid
-description: Start and manage a local Codex-styled multi-session dashboard with 2x2 and 3x3 layouts, voice input, guided prompts, and per-session model, speed, and reasoning controls. Use when the user asks to multi-open Codex sessions, view several tasks at once, open a four-grid or nine-grid workspace, monitor parallel Codex work, control session models, or launch/stop the Codex Session Grid sidecar.
+description: Start and manage a cross-platform local Codex-styled multi-session dashboard with 2x2 and 3x3 layouts, native single-send bridges for macOS and Windows, voice input, guided prompts, and model controls. Use when the user asks to multi-open Codex sessions, view several tasks at once, open a four-grid or nine-grid workspace, monitor parallel Codex work, or launch/stop the Codex Session Grid sidecar.
---
# Codex Session Grid
-Launch a localhost-only sidecar that talks to the bundled Codex app-server. It uses the existing Codex login and dynamically reads the installed model catalog; never ask for an API key. Messages sent from the grid use the macOS Accessibility bridge to submit through the native Codex App composer exactly once; the sidecar must never create a second `turn/start` for the same message.
+Launch a localhost-only sidecar that talks to the bundled Codex app-server. It uses the existing Codex login and dynamically reads the installed model catalog; never ask for an API key. Messages sent from the grid use the macOS Accessibility bridge or Windows UI Automation bridge to submit through the native Codex App composer exactly once; the sidecar must never create a second `turn/start` for the same message.
## Launch
@@ -15,11 +15,17 @@ Run the bundled launcher from this skill directory:
bash ../../scripts/start.sh
```
-Report the printed URL. The launcher reuses an existing healthy instance and opens it in the default browser on macOS or Linux.
+On Windows PowerShell, run:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File ../../scripts/start.ps1
+```
+
+Report the printed URL. The launcher reuses an existing healthy instance and opens it in the default browser.
When reporting the URL, include this first-use guidance: voice input requires a browser with Web Speech API support and microphone permission. After installing or updating the plugin, recommend starting a new Codex task so the latest skill is loaded. The same guidance is available from the help button in the workspace.
-Also explain that native single-send requires macOS Accessibility permission for Codex under System Settings → Privacy & Security → Accessibility. Sending opens and submits the native target task in the background, keeps the grid visible, and immediately shows an optimistic sent state while the same Session is synced. The native Codex App's current model, speed, and reasoning settings are authoritative for that send, so change them in the native composer when exact overrides are required.
+Also explain that macOS native single-send requires Accessibility permission for Codex under System Settings → Privacy & Security → Accessibility. On Windows, launch with PowerShell and keep the grid process at the same privilege level as Codex; if Codex runs as administrator, the launcher must also run as administrator. Sending opens and submits the native target task, restores the previous foreground window when possible, and immediately shows an optimistic sent state while the same Session is synced. The native Codex App's current model, speed, and reasoning settings are authoritative for that send, so change them in the native composer when exact overrides are required.
Use a different port only when requested or when the default port is occupied:
@@ -27,6 +33,8 @@ Use a different port only when requested or when the default port is occupied:
CODEX_SESSION_GRID_PORT=4318 bash ../../scripts/start.sh
```
+On Windows, pass `-Port 4318` to `start.ps1` and `stop.ps1`.
+
## Stop
Stop the default instance with:
@@ -35,6 +43,12 @@ Stop the default instance with:
bash ../../scripts/stop.sh
```
+On Windows PowerShell, run:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File ../../scripts/stop.ps1
+```
+
Pass the same `CODEX_SESSION_GRID_PORT` value when stopping a non-default instance.
## Guardrails
@@ -43,4 +57,5 @@ Pass the same `CODEX_SESSION_GRID_PORT` value when stopping a non-default instan
- Do not add authentication tokens or modify the user's Codex config.
- Treat the app-server API as experimental. If startup fails after a Codex update, run the launcher in the foreground with `node ../../scripts/server.mjs --port 4317` and report the protocol error.
- Do not claim that this plugin changes the native Codex desktop layout. It provides a companion workspace and deep-links individual tasks back to Codex.
-- Never reintroduce a sidecar `turn/start` as a fallback for web sends. If the native Accessibility bridge cannot find the composer, show the error and keep the draft so the user can retry without duplicate execution.
+- Never reintroduce a sidecar `turn/start` as a fallback for web sends. If either native bridge cannot find the composer, show the error and keep the draft so the user can retry without duplicate execution.
+- On Windows diagnostics, report only platform, tool versions, health, process name, element counts, and composer metadata. Do not emit Session messages or the user's draft.