From 5cf7f60d04dd1c2c9cdd5573bd7ab1cfb0a51716 Mon Sep 17 00:00:00 2001 From: yyf <2358929807@qq.com> Date: Fri, 21 Aug 2026 12:57:32 +0800 Subject: [PATCH 1/8] feat: add Windows support for Codex Micro --- README.md | 44 ++- README_zh.md | 40 +- docs/architecture.md | 8 +- docs/configuration.md | 8 +- docs/setup-and-operations.md | 165 ++++----- .../de_DE.json | 14 +- .../en.json | 18 +- .../es_ES.json | 14 +- .../ja_JP.json | 14 +- .../ko_KR.json | 14 +- .../manifest.json | 8 +- .../package-lock.json | 4 +- .../package.json | 2 +- .../plugin/app.js | 4 + .../plugin/bridge-installer.js | 342 ++++++++++-------- .../property-inspector/setup.html | 192 ++++------ .../pt_PT.json | 14 +- .../test/smoke.mjs | 56 +-- .../zh_CN.json | 18 +- .../zh_HK.json | 14 +- package-lock.json | 4 +- package.json | 6 +- scripts/install-plugin.mjs | 17 +- scripts/install.mjs | 202 +---------- scripts/launch.mjs | 13 + scripts/uninstall.mjs | 49 +-- skills/install-ulanzi-studio-plugin/SKILL.md | 19 +- skills/setup-codex-bridge/SKILL.md | 23 +- skills/setup-codex-bridge/agents/openai.yaml | 4 +- src/bridge/auth.mjs | 9 + src/bridge/codex-cdp.mjs | 24 +- src/bridge/platform.mjs | 197 ++++++++++ src/bridge/server.mjs | 26 +- test/bridge-installer_test.mjs | 87 +++++ test/bridge_test.mjs | 46 +++ 35 files changed, 896 insertions(+), 823 deletions(-) create mode 100644 scripts/launch.mjs create mode 100644 src/bridge/auth.mjs create mode 100644 src/bridge/platform.mjs diff --git a/README.md b/README.md index d8ba799..d06aa3d 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ Steer, Mic, Submit, and Latest Task & Scroll Encoder actions. | Local-only transport | Bind CDP and the Bridge API to loopback addresses only | This Ulanzi-maintained project is an unofficial integration with Codex Desktop -for **macOS, Ulanzi Studio, and Ulanzi D200 Series**. It is not affiliated with or -endorsed by OpenAI. The entire project was vibe-coded with Codex. +for **Windows or macOS, Ulanzi Studio, and Ulanzi D200 Series**. It is not +affiliated with or endorsed by OpenAI. Ulanzi's implementation and maintenance scope is limited to `integration/com.ulanzi.codexmicro.ulanziPlugin/`. The plugin only consumes @@ -38,8 +38,8 @@ responsibility boundary. ### Installation environment -- macOS 13 or later; -- Codex Desktop; +- Windows 10 or later, or macOS 13 or later; +- Codex Desktop Stable or Beta; - Ulanzi Studio 3.0.1 or later; - Ulanzi D200 Series connected to Ulanzi Studio; - Node.js 20 or newer only for repository-based manual installation. @@ -49,7 +49,8 @@ responsibility boundary. An LLM or coding agent must follow these steps: 1. Read [AGENTS.md](AGENTS.md) and inspect the installed Ulanzi Studio plugin - manifests under `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/`. + manifests under `%APPDATA%\Ulanzi\UlanziDeck\Plugins` on Windows or + `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins` on macOS. 2. Treat Codex Micro as installed only when manifest UUID `com.ulanzi.ulanzistudio.codexmicro` is present and its `CodePath` exists. 3. If it is missing or invalid, load @@ -57,7 +58,7 @@ An LLM or coding agent must follow these steps: install the plugin, and verify its manifest and entry point. 4. After the plugin is verified, load [`$setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md), install or repair - `Codex Bridge.app` and its sidecar, and verify the local health endpoint. + Codex Bridge and its sidecar, and verify the local health endpoint. 5. Report plugin installation and Bridge connectivity separately. Do not reinstall a valid plugin unless an update or repair was requested. @@ -66,13 +67,14 @@ An LLM or coding agent must follow these steps: After installing the Codex Micro plugin, drag any Codex Micro action onto a key and select it. Its shared **Codex Bridge Setup** page can: -- show whether `Codex Bridge.app`, the Bridge service, and CDP are available; +- show whether Codex Bridge, its user-level service, and CDP are available; - install or repair the bundled Bridge without a repository or npm directory; -- launch `~/Applications/Codex Bridge.app`; and +- launch Codex with loopback-only CDP arguments; and - recheck the connection or open the full installation guide. -The installer writes only to the current user's Applications, Application -Support, and LaunchAgents directories and does not require `sudo`. +The installer uses `%LOCALAPPDATA%\OpenCodexMicro` on Windows. On macOS it uses +the current user's Applications, Application Support, and LaunchAgents +directories. Neither path requires administrator access. ### 3. Manual installation @@ -87,11 +89,12 @@ npm run setup Quit Ulanzi Studio before running `npm run install:plugin`. The command validates and atomically installs the prebuilt plugin. `npm run setup` builds the loopback Bridge sidecar, registers its user LaunchAgent, and installs -`Codex Bridge.app` in `~/Applications`. +the platform-specific user-level Bridge runtime. -Reopen Ulanzi Studio. Quit Codex Desktop, then open -`~/Applications/Codex Bridge.app`; the wrapper starts Codex with a -loopback-only CDP endpoint. Confirm the connection with: +Reopen Ulanzi Studio and use **Launch Codex Bridge** in any Action setup page, +or run `npm run bridge:start`. On Windows the launcher discovers the current +Stable or Beta Appx package dynamically; it never pins a versioned WindowsApps +directory. Confirm the connection with: ```bash curl http://127.0.0.1:17373/health @@ -102,14 +105,9 @@ In Ulanzi Studio, drag the Codex Micro actions onto the desired keys. See [Setup and operations](docs/setup-and-operations.md) for installed paths, diagnostics, updates, and uninstall instructions. -> **Important:** When using Codex Micro, always launch Codex through -> `~/Applications/Codex Bridge.app`. Do not open Codex Desktop directly. -> -> Launch command: -> -> ```bash -> open ~/Applications/Codex\ Bridge.app -> ``` +> **Important:** Codex must be started with the loopback CDP arguments shown in +> the setup page. If `9222` is already available, Launch only focuses the current +> Codex instance; otherwise it starts the configured Stable or Beta channel. ## Configure @@ -132,7 +130,7 @@ The repository includes two reusable Codex skills: | Skill | Purpose | | --- | --- | -| [`setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md) | Install, update, verify, or repair `Codex Bridge.app` and its sidecar | +| [`setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md) | Install, update, verify, or repair Codex Bridge and its sidecar | | [`install-ulanzi-studio-plugin`](skills/install-ulanzi-studio-plugin/SKILL.md) | Install the repository's prebuilt plugin directory into Ulanzi Studio | ## Documentation diff --git a/README_zh.md b/README_zh.md index 4543475..0e68de5 100644 --- a/README_zh.md +++ b/README_zh.md @@ -34,8 +34,8 @@ Ulanzi 的实现与维护范围仅限 ### 安装环境 -- macOS 13 或更高版本; -- Codex Desktop; +- Windows 10 或更高版本,或 macOS 13 或更高版本; +- Codex Desktop Stable 或 Beta; - Ulanzi Studio 3.0.1 或更高版本; - 已在 Ulanzi Studio 中连接 Ulanzi D200 Series; - 仅使用仓库手动安装时需要 Node.js 20 或更高版本。 @@ -44,9 +44,9 @@ Ulanzi 的实现与维护范围仅限 LLM 或代码 Agent 必须按以下步骤执行: -1. 阅读 [AGENTS.md](AGENTS.md),检查 - `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/` 下已经安装的插件 - manifest。 +1. 阅读 [AGENTS.md](AGENTS.md),检查 Windows 的 + `%APPDATA%\Ulanzi\UlanziDeck\Plugins` 或 macOS 的 + `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins` 下已经安装的插件 manifest。 2. 仅当 manifest UUID 为 `com.ulanzi.ulanzistudio.codexmicro`,且其 `CodePath` 确实存在时,才判断 Codex Micro 已安装。 3. 如果插件缺失或无效,加载 @@ -54,7 +54,7 @@ LLM 或代码 Agent 必须按以下步骤执行: 安装插件并验证 manifest 和入口文件。 4. 插件验证通过后,加载 [`$setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md),安装或修复 - `Codex Bridge.app` 与 sidecar,并验证本机 health 接口。 + Codex Bridge 与 sidecar,并验证本机 health 接口。 5. 分别报告插件安装状态和 Bridge 连接状态。除非用户要求更新或修复,否则不要重复 安装有效插件。 @@ -63,13 +63,13 @@ LLM 或代码 Agent 必须按以下步骤执行: 安装 Codex Micro 插件后,把任意 Codex Micro Action 拖到按键并选中。所有 Action 共用的 **Codex Bridge 设置**页可以: -- 显示 `Codex Bridge.app`、Bridge 后台服务和 CDP 的状态; +- 显示 Codex Bridge、用户级后台进程和 CDP 的状态; - 使用插件内置资源安装或修复 Bridge,不需要仓库目录或 npm 项目目录; -- 启动 `~/Applications/Codex Bridge.app`; +- 使用仅限回环地址的 CDP 参数启动 Codex; - 重新检测连接或打开完整安装说明。 -安装器只写入当前用户的 Applications、Application Support 和 LaunchAgents 目录, -不需要 `sudo`。 +Windows 安装器写入 `%LOCALAPPDATA%\OpenCodexMicro`;macOS 安装器写入当前用户的 +Applications、Application Support 和 LaunchAgents 目录。两者都不需要管理员权限。 ### 3. 手动安装 @@ -82,11 +82,11 @@ npm run setup ``` 运行 `npm run install:plugin` 前请退出 Ulanzi Studio。该命令会校验并原子安装仓库 -中的预构建插件;`npm run setup` 会构建本机 Bridge sidecar、注册用户级 -LaunchAgent,并把 `Codex Bridge.app` 安装到 `~/Applications`。 +中的预构建插件;`npm run setup` 会构建并安装当前平台的用户级 Bridge sidecar。 -重新打开 Ulanzi Studio。完全退出 Codex Desktop,再打开 -`~/Applications/Codex Bridge.app`。可用以下命令确认连接: +重新打开 Ulanzi Studio,在任意 Action 设置页点击“启动 Codex Bridge”,或运行 +`npm run bridge:start`。Windows 会从 Appx 包元数据动态发现 Stable 或 Beta, +不会固定带版本号的 WindowsApps 目录。可用以下命令确认连接: ```bash curl http://127.0.0.1:17373/health @@ -96,14 +96,8 @@ curl http://127.0.0.1:17373/state 最后在 Ulanzi Studio 中把 Codex Micro actions 拖到需要的按键。安装路径、诊断、 更新与卸载方法见 [安装与运行](docs/setup-and-operations.md)。 -> **注意事项:** 使用 Codex Micro 时,必须通过 -> `~/Applications/Codex Bridge.app` 启动 Codex,请勿直接打开 Codex Desktop。 -> -> 启动命令: -> -> ```bash -> open ~/Applications/Codex\ Bridge.app -> ``` +> **注意事项:** Codex 必须带设置页所示的本机回环 CDP 参数启动。如果 `9222` +> 已在线,“启动”只会聚焦当前 Codex;否则会启动所选 Stable 或 Beta 通道。 ## 配置 @@ -123,7 +117,7 @@ Action 行为和布局建议见[配置详解](docs/configuration.md)。 | Skill | 用途 | | --- | --- | -| [`setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md) | 安装、更新、验证或修复 `Codex Bridge.app` 与 sidecar | +| [`setup-codex-bridge`](skills/setup-codex-bridge/SKILL.md) | 安装、更新、验证或修复 Codex Bridge 与 sidecar | | [`install-ulanzi-studio-plugin`](skills/install-ulanzi-studio-plugin/SKILL.md) | 把仓库中的预构建插件目录安装到 Ulanzi Studio | ## 文档 diff --git a/docs/architecture.md b/docs/architecture.md index fe3e9f7..c5a5425 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,8 +19,10 @@ Codex renderer Micro store ## Codex Bridge -`Codex Bridge.app` launches the real Codex executable with CDP restricted to -`127.0.0.1:9222`. The sidecar keeps a persistent renderer connection and +Codex Bridge launches the real Codex executable with CDP restricted to +`127.0.0.1:9222`. Windows discovers Stable or Beta through Appx metadata and the +Ulanzi plugin supervises the user-level sidecar; macOS retains the wrapper app +and user LaunchAgent. The sidecar keeps a persistent renderer connection and refreshes an in-memory snapshot every 500 ms. `/state` reads that cache rather than triggering a fresh renderer scan for every plugin poll. @@ -52,6 +54,6 @@ development, or maintenance of CDP or Codex's CDP implementation. ## Installation boundary - `scripts/install.mjs` installs only the Bridge sidecar and - `~/Applications/Codex Bridge.app`. + its platform-specific user-level launcher/lifecycle files. - `scripts/install-plugin.mjs` installs only the Ulanzi Studio plugin directory. - `scripts/uninstall.mjs` removes those two installed components. diff --git a/docs/configuration.md b/docs/configuration.md index 62ef190..886b45c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,10 +37,12 @@ placed on more than one key. ## Runtime requirements -- Start Codex through `~/Applications/Codex Bridge.app`. +- Start Codex from the Bridge setup page or with `npm run bridge:start`; Windows + resolves the current Stable or Beta Appx package dynamically. - Keep the Bridge sidecar running at `127.0.0.1:17373`. -- Allow Ulanzi Studio under macOS System Settings > Privacy & Security > - Accessibility so the Encoder can emit mouse-wheel events. +- On macOS, allow Ulanzi Studio under System Settings > Privacy & Security > + Accessibility so the Encoder can emit mouse-wheel events. On Windows, confirm + Ulanzi Studio is allowed to send the configured hotkey events. - Restart Ulanzi Studio after installing a new plugin build. The plugin does not require a separate device service, shortcut file, or theme diff --git a/docs/setup-and-operations.md b/docs/setup-and-operations.md index f21d2b6..fa65b80 100644 --- a/docs/setup-and-operations.md +++ b/docs/setup-and-operations.md @@ -8,72 +8,63 @@ Codex Desktop <-> Codex Bridge sidecar <-> Ulanzi Studio plugin <-> D200 ## Dependencies -- macOS; -- Codex Desktop; -- Ulanzi Studio with a supported keypad device; -- Node.js 20 or newer only for repository-based manual installation. +- Windows 10+ or macOS 13+; +- Codex Desktop Stable or Beta; +- Ulanzi Studio 3.0.1+ with a supported D200 Series device; +- Node.js 20+ only for repository-based installation. -## 1. Set up Codex Bridge.app in Ulanzi Studio +## Install from Ulanzi Studio -Drag any Codex Micro action onto a key and select it. In the shared -**Codex Bridge Setup** page, choose **Install / Repair**. The plugin installs -its bundled Bridge runtime, wrapper app, and user LaunchAgent without requiring -a repository path, npm, or administrator access. Then choose -**Launch Codex Bridge** and wait for the CDP status to become connected. +Drag any Codex Micro Action onto a key and select it. In the shared **Codex +Bridge Setup** page, choose **Install / Repair**, then **Launch Codex Bridge**. +The bundled installer requires no repository path, npm directory, administrator +service, or fixed Codex package version. -## 2. Set up Codex Bridge.app from the repository +Windows installs Bridge data under: -From the repository root: - -```bash -npm install -npm run setup +```text +%LOCALAPPDATA%\OpenCodexMicro ``` -The installer builds the sidecar, writes and starts -`io.opencodexmicro.bridge`, and ad-hoc signs: +The Ulanzi plugin starts and supervises the user-level Bridge process. The +launcher reads Appx metadata for `OpenAI.Codex` and `OpenAI.CodexBeta`, preferring +Stable unless `CODEX_DESKTOP_CHANNEL=beta` is configured. + +macOS keeps the wrapper and LaunchAgent flow: ```text ~/Applications/Codex Bridge.app +~/Library/Application Support/OpenCodexMicro +~/Library/LaunchAgents/io.opencodexmicro.bridge.plist ``` -To install without starting the sidecar LaunchAgent: - -```bash -npm run setup -- --no-start -``` - -## 3. Install the Ulanzi Studio plugin +## Install from the repository -Quit Ulanzi Studio before replacing a plugin that is currently loaded, then -run: +From `plugins/codex-console`: -```bash +```text +npm install npm run install:plugin +npm run setup ``` -The repository ships the prebuilt `dist/app.js`. The command validates that -the manifest entry point exists and atomically replaces: +Quit Ulanzi Studio before replacing a loaded plugin. `install:plugin` validates +the manifest, entry point, Property Inspector, icons, banners, locales, and +bundled Bridge resources before atomically replacing the installed directory. + +Plugin destinations are: ```text -~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/com.ulanzi.codexmicro.ulanziPlugin +Windows: %APPDATA%\Ulanzi\UlanziDeck\Plugins\com.ulanzi.codexmicro.ulanziPlugin +macOS: ~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/com.ulanzi.codexmicro.ulanziPlugin ``` -Restart Ulanzi Studio and confirm the **Codex Micro** category and actions are -visible. - -Plugin development still uses `npm run build:plugin` and `npm run check`; commit -the updated `dist/app.js` and `dist/package.json` with source changes. +Use `npm run setup -- --no-start` to install Bridge without launching Codex, or +`npm run bridge:start` to launch an existing installation. -## Starting Codex - -Quit a normally launched Codex instance, then open: - -```text -~/Applications/Codex Bridge.app -``` +## Codex and CDP lifecycle -The wrapper launches `/Applications/ChatGPT.app/Contents/MacOS/ChatGPT` with: +Codex must be launched with: ```text --remote-debugging-address=127.0.0.1 @@ -81,77 +72,63 @@ The wrapper launches `/Applications/ChatGPT.app/Contents/MacOS/ChatGPT` with: --remote-allow-origins=http://127.0.0.1:9222 ``` -CDP binds to `127.0.0.1:9222` and the sidecar API to `127.0.0.1:17373`. -Neither endpoint is reachable from the LAN. +Bridge probes `127.0.0.1:9222` first. If it is already available, Launch only +focuses Codex. If it is unavailable on Windows, Launch dynamically resolves the +selected Appx package, stops only that channel's main process, and starts its +current executable with the arguments above. No versioned WindowsApps path is +stored. -## Installed files and service +CDP binds to `127.0.0.1:9222` and Bridge to `127.0.0.1:17373`. Bridge `POST` +actions require the random capability token stored in the user data directory; +the plugin reads it locally and never sends it to the Property Inspector. -```text -~/Applications/Codex Bridge.app -~/Library/Application Support/OpenCodexMicro/bridge.mjs -~/Library/Application Support/OpenCodexMicro/bridge.log -~/Library/Application Support/OpenCodexMicro/bridge-error.log -~/Library/LaunchAgents/io.opencodexmicro.bridge.plist -~/Library/Application Support/Ulanzi/UlanziDeck/Plugins/com.ulanzi.codexmicro.ulanziPlugin -``` +## Diagnostics -Inspect or restart the Bridge sidecar: +Read-only health endpoints remain available on loopback: -```bash -launchctl print "gui/$(id -u)/io.opencodexmicro.bridge" -launchctl kickstart -k "gui/$(id -u)/io.opencodexmicro.bridge" +```text +http://127.0.0.1:17373/health +http://127.0.0.1:17373/state +http://127.0.0.1:9222/json/version +http://127.0.0.1:9222/json/list ``` -## Diagnostics - -```bash -curl http://127.0.0.1:17373/health -curl http://127.0.0.1:17373/state -tail -f "$HOME/Library/Application Support/OpenCodexMicro/bridge.log" -tail -f "$HOME/Library/Application Support/OpenCodexMicro/bridge-error.log" -``` +Windows logs can be inspected from the Ulanzi Studio plugin log and the Bridge +status page. macOS sidecar logs remain under +`~/Library/Application Support/OpenCodexMicro`. | Symptom | Check | | --- | --- | -| Plugin category is missing | Confirm the installed plugin directory contains `manifest.json` and `dist/app.js`, then restart Ulanzi Studio | -| Plugin keys show offline | Start Codex with `Codex Bridge.app` and check `/health` and `/state` | -| A task key does not switch | Check `bridge-error.log` and confirm `/state` contains the displayed thread | -| Steer does nothing | Confirm a running task exposes the visible composer Steer action | -| An action has no effect | Confirm the plugin can reach `127.0.0.1:17373` and inspect the Bridge log | +| Plugin category is missing | Confirm the platform plugin directory contains `manifest.json` and `dist/app.js`, then restart Ulanzi Studio | +| Bridge is not installed | Use **Install / Repair** and confirm the user data directory contains `bridge.mjs`, `bridge-token`, and `install.json` | +| Bridge is offline | Use **Launch Codex Bridge**; on Windows confirm no endpoint security product blocked the user-level Node process | +| CDP is disconnected | Confirm Codex was launched with the three loopback arguments and `/json/version` responds | +| A task key does not switch | Confirm `/state` contains the displayed thread and the current Codex renderer still exposes Micro handlers | +| Usage is blank | Treat `rate-limit-status: null` as unavailable account data, not as a Windows transport failure | -## Update +## Update and uninstall -```bash -git pull +```text npm install npm run check npm run setup npm run install:plugin ``` -Restart Ulanzi Studio after updating the plugin. - -## Uninstall +Restart Ulanzi Studio after updating the plugin. Remove Bridge and the installed +plugin only when explicitly intended: -```bash +```text npm run uninstall ``` -This stops and removes the Bridge LaunchAgent, Bridge runtime, wrapper app, and -installed Codex Micro plugin directory. +The Windows uninstaller stops only the Bridge process whose command line points +to the installed `bridge.mjs`; it does not remove or terminate Codex Desktop. ## Development -```bash -npm install -npm run check -``` - -Main entry points: - -- `src/bridge/`: Codex renderer bridge; -- `scripts/build-bridge.mjs`: bundle the loopback sidecar; -- `scripts/install.mjs`: install the sidecar and wrapper app; -- `scripts/install-plugin.mjs`: validate and install the prebuilt Ulanzi plugin; -- `integration/com.ulanzi.codexmicro.ulanziPlugin/`: Ulanzi Studio plugin; -- `scripts/uninstall.mjs`: remove both installed components. +The committed release entry points are +`integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js` and +`integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs`. Run the +repository verification gate after platform, installer, manifest, localization, +or build changes. diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json index fb0a678..fcdd217 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Codex-Desktop-Aufgaben und -Aktionen mit der Ulanzi D200 Series in Ulanzi Studio steuern.", - "Description": "Verwandeln Sie die Ulanzi D200 Series über Ulanzi Studio in eine dedizierte Steueroberfläche für Codex Desktop.\n\nFUNKTIONEN\n- Zeigt fünf aktuelle Codex-Aufgaben mit Live-Status und öffnet exakt die ausgewählte Aufgabe.\n- Bietet Fast, Usage, Pin, New, Fork, Steer, Mic und Submit.\n- Mit Latest Task & Scroll öffnen Sie task1 und scrollen Codex über den Encoder.\n- Codex Bridge über die Einstellungsseite jeder Action installieren, reparieren, starten und prüfen.\n- Hält die Kommunikation über Codex Bridge.app und ausschließlich lokale Loopback-Endpunkte auf dem Mac.\n\nINSTALLATIONSUMGEBUNG\n- macOS 13 oder neuer\n- Codex Desktop\n- Ulanzi Studio 3.0.1 oder neuer\n- In Ulanzi Studio verbundene Ulanzi D200 Series\n- Node.js 20 oder neuer nur für die manuelle Einrichtung aus dem Repository\n\nLLM-/AGENT-INSTALLATION\nGeben Sie in Ihrem Agent ein:\nInstalliere bitte das Ulanzi-Studio-Plugin für mich: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nMANUELLE INSTALLATION\n1. https://github.com/UlanziTechnology/OpenCodexMicro klonen und das Repository öffnen.\n2. Ulanzi Studio beenden.\n3. Ausführen: npm install\n4. Ausführen: npm run install:plugin\n5. Ausführen: npm run setup\n6. Ulanzi Studio erneut öffnen, Codex Desktop beenden und ~/Applications/Codex Bridge.app starten.\n7. Die Codex-Micro-Aktionen in Ulanzi Studio den gewünschten Tasten hinzufügen.\n8. Eine Action auswählen und Installation, Dienst sowie CDP auf der Codex-Bridge-Seite prüfen.\n\nWICHTIG\nStarten Sie Codex bei Verwendung von Codex Micro immer über ~/Applications/Codex Bridge.app. Öffnen Sie Codex Desktop nicht direkt.\nStartbefehl: open ~/Applications/Codex\\ Bridge.app", + "Description": "Steuert Codex Desktop mit der Ulanzi D200 Series in Ulanzi Studio. Unterstützt fünf aktuelle Aufgaben, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit und Encoder-Navigation unter Windows 10 oder neuer sowie macOS 13 oder neuer. Unter Windows erkennt die Bridge Stable oder Beta dynamisch; Schreibaktionen sind durch ein lokales Capability-Token geschützt und CDP bleibt auf Loopback. Für die Repository-Einrichtung: npm install, npm run install:plugin und npm run setup.", "Actions": [ { "Name": "Codex-Aufgabe 1", "Tooltip": "Öffnet den letzten Codex-Aufgabenplatz 1 und zeigt seinen Live-Status." }, { "Name": "Codex-Aufgabe 2", "Tooltip": "Öffnet den letzten Codex-Aufgabenplatz 2 und zeigt seinen Live-Status." }, @@ -21,7 +21,7 @@ "Localization": { "BridgeSetup": "Codex Bridge einrichten", "BridgeSetupDescription": "Lokale Codex-Verbindung installieren, starten und prüfen.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge-Dienst", "CDPConnection": "CDP-Verbindung", "InstallRepair": "Installieren / Reparieren", @@ -30,14 +30,14 @@ "UninstallingBridge": "Wird deinstalliert…", "UninstallBridgeDone": "Codex Bridge wurde deinstalliert. Das Ulanzi-Plugin und Codex Desktop bleiben erhalten.", "UninstallBridgeConfirm": "Codex Bridge und den Hintergrunddienst deinstallieren? Das Ulanzi-Plugin und Codex Desktop werden nicht entfernt.", - "BridgeAppInfoTitle": "Was ist Codex Bridge.app?", - "BridgeAppDescription": "Codex Bridge.app ist ein lokaler Starter. Falls Codex bereits läuft, wird es regulär beendet und anschließend die offizielle Codex-Desktop-Datei mit ausschließlich lokalen CDP-Startparametern gestartet.", - "BridgeAppSafety": "Der Starter verändert weder Codex Desktop noch Konten, Projekte oder Benutzerdateien und gibt CDP nicht außerhalb dieses Macs frei. Er führt keine weiteren privilegierten oder destruktiven Aktionen aus. Der separate Bridge-Dienst übernimmt lokalen Status und Action-Kommunikation.", + "BridgeAppInfoTitle": "Wie startet Codex Bridge Codex?", + "BridgeAppDescription": "Unter Windows erkennt Codex Bridge Stable oder Beta dynamisch und startet die offizielle Anwendung mit Loopback-CDP-Argumenten; unter macOS verwendet es die lokale Wrapper-App.", + "BridgeAppSafety": "Der Starter verändert weder Codex Desktop noch Konten, Projekte oder Benutzerdateien. CDP bleibt lokal und Bridge-Schreibaktionen benötigen ein lokales Capability-Token.", "BackgroundActivityTitle": "Hintergrundaktivität und Node.js", - "LaunchAgentDescription": "Der LaunchAgent startet den lokalen Codex-Bridge-Dienst bei der Anmeldung und startet ihn nach einem Beenden neu. Der Dienst lauscht nur über eine Loopback-Adresse auf diesem Mac.", + "LaunchAgentDescription": "Unter Windows überwacht das Ulanzi-Plugin den Bridge-Prozess auf Benutzerebene, unter macOS ein Benutzer-LaunchAgent. Der Dienst lauscht nur auf Loopback.", "NodeSelectionStrategy": "Installieren / Reparieren bevorzugt Node.js 20 oder neuer aus Anmeldeumgebung, PATH, Homebrew oder üblichen Installationspfaden. Nur ersatzweise wird das in Ulanzi Studio enthaltene Node verwendet.", "NodeInstallRecommendation": "Empfohlen: Installieren Sie die neueste Node.js-Version im Terminal und führen Sie Installieren / Reparieren erneut aus:", - "UlanziNodeSignatureNotice": "Beim Rückgriff auf Ulanzi Studio Node kann macOS unter Hintergrundobjekte eine Aktivität mit Ulanzi-Studio-Signatur anzeigen. Dabei läuft der Codex-Bridge-Dienst mit der Node-Datei von Ulanzi Studio.", + "UlanziNodeSignatureNotice": "Die Bridge läuft mit Benutzerrechten und installiert keinen Windows-Dienst auf Administratorebene.", "CurrentNodeRuntime": "Aktuelle Laufzeit: {runtime} {version}", "SystemNode": "System-Node.js", "UlanziNode": "Ulanzi Studio Node", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json index 4df2379..b8f3c3f 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Control Codex Desktop tasks and actions from Ulanzi D200 Series in Ulanzi Studio.", - "Description": "Turn Ulanzi D200 Series into a dedicated Codex Desktop control surface through Ulanzi Studio.\n\nFEATURES\n- View five recent Codex tasks with live status and open the exact selected task.\n- Use Fast, Usage, Pin, New, Fork, Steer, Mic, and Submit actions.\n- Use the Latest Task & Scroll Encoder action to open task 1 and scroll Codex.\n- Install, repair, launch, and check Codex Bridge from every Action setup page.\n- Keep communication local through Codex Bridge.app and loopback-only endpoints.\n\nINSTALLATION ENVIRONMENT\n- macOS 13 or later\n- Codex Desktop\n- Ulanzi Studio 3.0.1 or later\n- Ulanzi D200 Series connected in Ulanzi Studio\n- Node.js 20 or later only for manual repository setup\n\nLLM / AGENT INSTALLATION\nIn your Agent, enter:\nInstall the Ulanzi Studio plugin for me: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nMANUAL INSTALLATION\n1. Clone https://github.com/UlanziTechnology/OpenCodexMicro and open the repository directory.\n2. Quit Ulanzi Studio.\n3. Run: npm install\n4. Run: npm run install:plugin\n5. Run: npm run setup\n6. Reopen Ulanzi Studio, quit Codex Desktop, and launch ~/Applications/Codex Bridge.app.\n7. Add the Codex Micro actions to your keys in Ulanzi Studio.\n8. Select any Action to check Bridge installation, service, and CDP status.\n\nIMPORTANT\nWhen using Codex Micro, always launch Codex through ~/Applications/Codex Bridge.app. Do not open Codex Desktop directly.\nLaunch command: open ~/Applications/Codex\\ Bridge.app", + "Description": "Control Codex Desktop from Ulanzi D200 Series through Ulanzi Studio. View five recent tasks, use Fast, Usage, Pin, New, Fork, Steer, Mic and Submit, and navigate with the Encoder. Supports Windows 10 or later and macOS 13 or later. Codex Bridge dynamically locates Windows Stable or Beta and keeps authenticated Bridge actions and CDP on loopback. For repository setup run npm install, npm run install:plugin, and npm run setup.", "Actions": [ { "Name": "Codex Task 1", "Tooltip": "Open recent Codex task slot 1 and show its live status." }, { "Name": "Codex Task 2", "Tooltip": "Open recent Codex task slot 2 and show its live status." }, @@ -21,7 +21,7 @@ "Localization": { "BridgeSetup": "Codex Bridge Setup", "BridgeSetupDescription": "Install, launch, and check the local Codex connection.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge service", "CDPConnection": "CDP connection", "InstallRepair": "Install / Repair", @@ -30,14 +30,14 @@ "UninstallingBridge": "Uninstalling…", "UninstallBridgeDone": "Codex Bridge was uninstalled. The Ulanzi plugin and Codex Desktop were kept.", "UninstallBridgeConfirm": "Uninstall Codex Bridge and its background service? The Ulanzi plugin and Codex Desktop will not be removed.", - "BridgeAppInfoTitle": "What is Codex Bridge.app?", - "BridgeAppDescription": "Codex Bridge.app is a local launcher. If Codex is already running, it asks Codex to quit, then starts the official Codex Desktop executable with loopback-only CDP arguments.", - "BridgeAppSafety": "The launcher does not modify Codex Desktop, accounts, projects, or user files, and it does not expose CDP outside this Mac. It performs no other privileged or destructive operation. The separate Bridge service handles local status and Action communication.", + "BridgeAppInfoTitle": "How does Codex Bridge start Codex?", + "BridgeAppDescription": "On Windows, Codex Bridge dynamically locates Stable or Beta and starts the official executable with loopback-only CDP arguments. On macOS it uses the local wrapper app.", + "BridgeAppSafety": "The launcher does not modify Codex Desktop, accounts, projects, or user files. CDP stays on this computer, and Bridge write actions require a local capability token.", "BackgroundActivityTitle": "Background activity and Node.js", - "LaunchAgentDescription": "The LaunchAgent starts the local Codex Bridge service when you sign in and restarts it if it exits. The service listens only on this Mac through a loopback address.", - "NodeSelectionStrategy": "Install / Repair first selects a system Node.js 20 or later from your login environment, PATH, Homebrew, or common install paths. It uses Ulanzi Studio's bundled Node only as a fallback.", - "NodeInstallRecommendation": "Recommended: install the latest Node.js from Terminal, then run Install / Repair again:", - "UlanziNodeSignatureNotice": "If the Ulanzi Studio Node fallback is used, macOS Background Items may show an activity carrying the Ulanzi Studio signature. This is the Codex Bridge service running with Ulanzi Studio's Node executable.", + "LaunchAgentDescription": "The Ulanzi plugin supervises the user-level Bridge process on Windows; a user LaunchAgent supervises it on macOS. The service listens only on loopback.", + "NodeSelectionStrategy": "Install / Repair selects Node.js 20 or later from PATH, then uses Ulanzi Studio's bundled Node as a fallback.", + "NodeInstallRecommendation": "For repository setup, verify that Node.js 20 or later is available:", + "UlanziNodeSignatureNotice": "The Bridge runs with user permissions and does not install an administrator-level service.", "CurrentNodeRuntime": "Current runtime: {runtime} {version}", "SystemNode": "System Node.js", "UlanziNode": "Ulanzi Studio Node", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/es_ES.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/es_ES.json index 85f7856..6af8c85 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/es_ES.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/es_ES.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Controla tareas y acciones de Codex Desktop con Ulanzi D200 Series en Ulanzi Studio.", - "Description": "Convierte Ulanzi D200 Series en una superficie de control dedicada para Codex Desktop mediante Ulanzi Studio.\n\nFUNCIONES\n- Muestra cinco tareas recientes de Codex con estado en tiempo real y abre exactamente la tarea seleccionada.\n- Incluye las acciones Fast, Usage, Pin, New, Fork, Steer, Mic y Submit.\n- Usa el Encoder Latest Task & Scroll para abrir task1 y desplazarte por Codex.\n- Instala, repara, inicia y comprueba Codex Bridge desde la página de configuración de cada Action.\n- Mantiene la comunicación local mediante Codex Bridge.app y endpoints exclusivos de loopback.\n\nENTORNO DE INSTALACIÓN\n- macOS 13 o posterior\n- Codex Desktop\n- Ulanzi Studio 3.0.1 o posterior\n- Ulanzi D200 Series conectada en Ulanzi Studio\n- Node.js 20 o posterior solo para la configuración manual desde el repositorio\n\nINSTALACIÓN CON LLM / AGENT\nEscribe en tu Agent:\nInstálame el plugin de Ulanzi Studio: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nINSTALACIÓN MANUAL\n1. Clona https://github.com/UlanziTechnology/OpenCodexMicro y abre la carpeta del repositorio.\n2. Cierra Ulanzi Studio.\n3. Ejecuta: npm install\n4. Ejecuta: npm run install:plugin\n5. Ejecuta: npm run setup\n6. Vuelve a abrir Ulanzi Studio, cierra Codex Desktop e inicia ~/Applications/Codex Bridge.app.\n7. Añade las acciones de Codex Micro a las teclas deseadas en Ulanzi Studio.\n8. Selecciona una Action y comprueba la instalación, el servicio y CDP en la página de Codex Bridge.\n\nIMPORTANTE\nAl usar Codex Micro, inicia siempre Codex mediante ~/Applications/Codex Bridge.app. No abras Codex Desktop directamente.\nComando de inicio: open ~/Applications/Codex\\ Bridge.app", + "Description": "Controla Codex Desktop con Ulanzi D200 Series en Ulanzi Studio. Incluye cinco tareas recientes, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit y navegación con Encoder en Windows 10 o posterior y macOS 13 o posterior. En Windows, Bridge encuentra Stable o Beta dinámicamente; las acciones de escritura usan un token local y CDP permanece en loopback. Para configurar desde el repositorio ejecuta npm install, npm run install:plugin y npm run setup.", "Actions": [ { "Name": "Tarea Codex 1", "Tooltip": "Abre la posición 1 de las tareas recientes de Codex y muestra su estado en tiempo real." }, { "Name": "Tarea Codex 2", "Tooltip": "Abre la posición 2 de las tareas recientes de Codex y muestra su estado en tiempo real." }, @@ -21,7 +21,7 @@ "Localization": { "BridgeSetup": "Configuración de Codex Bridge", "BridgeSetupDescription": "Instala, inicia y comprueba la conexión local de Codex.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Servicio Bridge", "CDPConnection": "Conexión CDP", "InstallRepair": "Instalar / Reparar", @@ -30,14 +30,14 @@ "UninstallingBridge": "Desinstalando…", "UninstallBridgeDone": "Codex Bridge se ha desinstalado. El plugin de Ulanzi y Codex Desktop se conservaron.", "UninstallBridgeConfirm": "¿Desinstalar Codex Bridge y su servicio en segundo plano? El plugin de Ulanzi y Codex Desktop no se eliminarán.", - "BridgeAppInfoTitle": "¿Qué es Codex Bridge.app?", - "BridgeAppDescription": "Codex Bridge.app es un iniciador local. Si Codex ya está abierto, solicita su cierre normal y después inicia el ejecutable oficial de Codex Desktop con argumentos CDP limitados al bucle local.", - "BridgeAppSafety": "El iniciador no modifica Codex Desktop, cuentas, proyectos ni archivos del usuario, y no expone CDP fuera de este Mac. No realiza ninguna otra operación privilegiada o destructiva. El servicio Bridge independiente gestiona el estado local y la comunicación con las Actions.", + "BridgeAppInfoTitle": "¿Cómo inicia Codex Bridge a Codex?", + "BridgeAppDescription": "En Windows, Codex Bridge encuentra Stable o Beta dinámicamente e inicia la aplicación oficial con argumentos CDP de loopback; en macOS usa la aplicación envoltorio local.", + "BridgeAppSafety": "El iniciador no modifica Codex Desktop, cuentas, proyectos ni archivos del usuario. CDP permanece local y las acciones de escritura de Bridge requieren un token local.", "BackgroundActivityTitle": "Actividad en segundo plano y Node.js", - "LaunchAgentDescription": "LaunchAgent inicia el servicio local Codex Bridge al entrar en la sesión y lo reinicia si termina. El servicio solo escucha en este Mac mediante una dirección de bucle local.", + "LaunchAgentDescription": "En Windows, el plugin de Ulanzi supervisa Bridge a nivel de usuario; en macOS lo hace un LaunchAgent del usuario. El servicio solo escucha en loopback.", "NodeSelectionStrategy": "Instalar / Reparar prioriza Node.js 20 o posterior del entorno de inicio, PATH, Homebrew o rutas habituales. Solo usa el Node incluido en Ulanzi Studio como alternativa.", "NodeInstallRecommendation": "Recomendado: instala la versión más reciente de Node.js desde Terminal y vuelve a ejecutar Instalar / Reparar:", - "UlanziNodeSignatureNotice": "Si se usa el Node de Ulanzi Studio como alternativa, los elementos en segundo plano de macOS pueden mostrar una actividad con la firma de Ulanzi Studio. Es el servicio Codex Bridge ejecutándose con el Node de Ulanzi Studio.", + "UlanziNodeSignatureNotice": "Bridge se ejecuta con permisos de usuario y no instala un servicio de Windows a nivel de administrador.", "CurrentNodeRuntime": "Entorno actual: {runtime} {version}", "SystemNode": "Node.js del sistema", "UlanziNode": "Node de Ulanzi Studio", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json index a3c82cd..46b9174 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Ulanzi Studio の Ulanzi D200 Series から Codex Desktop のタスクと操作を制御します。", - "Description": "Ulanzi Studio を通じて、Ulanzi D200 Series を Codex Desktop 専用のコントロールサーフェスにします。\n\n機能\n- 最近の 5 件の Codex タスクをリアルタイム状態付きで表示し、選択したタスクを正確に開きます。\n- Fast、Usage、Pin、New、Fork、Steer、Mic、Submit を操作できます。\n- Latest Task & Scroll Encoder で task1 を開き、Codex を上下にスクロールできます。\n- 各 Action の設定ページから Codex Bridge のインストール、修復、起動、確認ができます。\n- Codex Bridge.app とループバック限定エンドポイントを使用して通信をローカルに保ちます。\n\nインストール環境\n- macOS 13 以降\n- Codex Desktop\n- Ulanzi Studio 3.0.1 以降\n- Ulanzi Studio に接続された Ulanzi D200 Series\n- リポジトリから手動設定する場合のみ Node.js 20 以降\n\nLLM / AGENT インストール\nAgent に次のように入力します:\nUlanzi Studio プラグインをインストールしてください:[https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\n手動インストール\n1. https://github.com/UlanziTechnology/OpenCodexMicro をクローンしてリポジトリを開きます。\n2. Ulanzi Studio を終了します。\n3. 実行:npm install\n4. 実行:npm run install:plugin\n5. 実行:npm run setup\n6. Ulanzi Studio を再度開き、Codex Desktop を終了して ~/Applications/Codex Bridge.app を起動します。\n7. Ulanzi Studio で Codex Micro actions をキーに追加します。\n8. 任意の Action を選択し、Codex Bridge 設定ページでインストール、サービス、CDP の状態を確認します。\n\n重要事項\nCodex Micro を使用する際は、必ず ~/Applications/Codex Bridge.app から Codex を起動してください。Codex Desktop を直接開かないでください。\n起動コマンド: open ~/Applications/Codex\\ Bridge.app", + "Description": "Ulanzi Studio と Ulanzi D200 Series から Codex Desktop を操作します。最近の 5 タスク、Fast、Usage、Pin、New、Fork、Steer、Mic、Submit、Encoder ナビゲーションに対応します。Windows 10 以降と macOS 13 以降をサポートします。Windows Bridge は Stable または Beta を動的に検出し、Bridge の書き込み操作はローカル能力トークンで保護され、CDP はループバックだけを待ち受けます。リポジトリ設定では npm install、npm run install:plugin、npm run setup を実行します。", "Actions": [ { "Name": "Codex タスク 1", "Tooltip": "最近の Codex タスクスロット 1 を開き、リアルタイム状態を表示します。" }, { "Name": "Codex タスク 2", "Tooltip": "最近の Codex タスクスロット 2 を開き、リアルタイム状態を表示します。" }, @@ -21,7 +21,7 @@ "Localization": { "BridgeSetup": "Codex Bridge 設定", "BridgeSetupDescription": "ローカルの Codex 接続をインストール、起動、確認します。", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge サービス", "CDPConnection": "CDP 接続", "InstallRepair": "インストール / 修復", @@ -30,14 +30,14 @@ "UninstallingBridge": "アンインストール中…", "UninstallBridgeDone": "Codex Bridge をアンインストールしました。Ulanzi プラグインと Codex Desktop は保持されます。", "UninstallBridgeConfirm": "Codex Bridge とバックグラウンドサービスをアンインストールしますか?Ulanzi プラグインと Codex Desktop は削除されません。", - "BridgeAppInfoTitle": "Codex Bridge.app とは?", - "BridgeAppDescription": "Codex Bridge.app はローカルランチャーです。Codex が実行中の場合は正常終了を要求し、ループバック専用の CDP 起動引数で公式 Codex Desktop 実行ファイルを起動します。", - "BridgeAppSafety": "このランチャーは Codex Desktop、アカウント、プロジェクト、ユーザーファイルを変更せず、CDP をこの Mac の外部に公開しません。その他の権限昇格や破壊的操作も行いません。別の Bridge サービスがローカル状態と Action 通信を処理します。", + "BridgeAppInfoTitle": "Codex Bridge はどのように Codex を起動しますか?", + "BridgeAppDescription": "Windows では Stable または Beta を動的に検出し、ループバック専用の CDP 引数で公式プログラムを起動します。macOS ではローカルラッパーアプリを使用します。", + "BridgeAppSafety": "ランチャーは Codex Desktop、アカウント、プロジェクト、ユーザーファイルを変更しません。CDP はローカル限定で、Bridge の書き込み操作にはローカル能力トークンが必要です。", "BackgroundActivityTitle": "バックグラウンド動作と Node.js", - "LaunchAgentDescription": "LaunchAgent はログイン時にローカル Codex Bridge サービスを起動し、終了した場合は再起動します。サービスはループバックアドレスでこの Mac 内だけを待ち受けます。", + "LaunchAgentDescription": "Windows では Ulanzi プラグイン、macOS ではユーザー LaunchAgent がユーザー権限の Bridge を監視します。サービスはループバックだけを待ち受けます。", "NodeSelectionStrategy": "インストール / 修復では、ログイン環境、PATH、Homebrew、一般的なパスから Node.js 20 以降を優先し、見つからない場合のみ Ulanzi Studio 内蔵 Node を使用します。", "NodeInstallRecommendation": "推奨:Terminal で最新の Node.js をインストールし、インストール / 修復を再実行してください:", - "UlanziNodeSignatureNotice": "Ulanzi Studio Node にフォールバックすると、macOS のバックグラウンド項目に Ulanzi Studio の署名付きアクティビティが表示されることがあります。これは Codex Bridge サービスが Ulanzi Studio の Node 実行ファイルで動作しているためです。", + "UlanziNodeSignatureNotice": "Bridge は現在のユーザー権限で動作し、管理者レベルの Windows サービスはインストールしません。", "CurrentNodeRuntime": "現在のランタイム:{runtime} {version}", "SystemNode": "システム Node.js", "UlanziNode": "Ulanzi Studio Node", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/ko_KR.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/ko_KR.json index 2cbe818..a6eb22a 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/ko_KR.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/ko_KR.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Ulanzi Studio의 Ulanzi D200 Series에서 Codex Desktop 작업과 동작을 제어합니다.", - "Description": "Ulanzi Studio를 통해 Ulanzi D200 Series를 Codex Desktop 전용 컨트롤 표면으로 사용합니다.\n\n기능\n- 최근 Codex 작업 5개의 실시간 상태를 표시하고 선택한 작업을 정확히 엽니다.\n- Fast, Usage, Pin, New, Fork, Steer, Mic, Submit 동작을 제공합니다.\n- Latest Task & Scroll Encoder로 task1을 열고 Codex를 위아래로 스크롤합니다.\n- 각 Action 설정 페이지에서 Codex Bridge를 설치, 복구, 실행하고 상태를 확인합니다.\n- Codex Bridge.app과 루프백 전용 엔드포인트를 통해 통신을 로컬로 유지합니다.\n\n설치 환경\n- macOS 13 이상\n- Codex Desktop\n- Ulanzi Studio 3.0.1 이상\n- Ulanzi Studio에 연결된 Ulanzi D200 Series\n- 저장소에서 수동 설정할 때만 Node.js 20 이상 필요\n\nLLM / AGENT 설치\nAgent에 다음과 같이 입력하세요:\nUlanzi Studio 플러그인을 설치해 주세요: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\n수동 설치\n1. https://github.com/UlanziTechnology/OpenCodexMicro 저장소를 복제하고 디렉터리를 엽니다.\n2. Ulanzi Studio를 종료합니다.\n3. 실행: npm install\n4. 실행: npm run install:plugin\n5. 실행: npm run setup\n6. Ulanzi Studio를 다시 열고 Codex Desktop을 종료한 다음 ~/Applications/Codex Bridge.app을 실행합니다.\n7. Ulanzi Studio에서 Codex Micro actions를 원하는 키에 추가합니다.\n8. Action을 선택하고 Codex Bridge 설정 페이지에서 설치, 서비스, CDP 상태를 확인합니다.\n\n중요 사항\nCodex Micro를 사용할 때는 항상 ~/Applications/Codex Bridge.app을 통해 Codex를 실행하세요. Codex Desktop을 직접 열지 마세요.\n실행 명령: open ~/Applications/Codex\\ Bridge.app", + "Description": "Ulanzi Studio의 Ulanzi D200 Series에서 Codex Desktop을 제어합니다. 최근 작업 5개, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit 및 Encoder 탐색을 Windows 10 이상과 macOS 13 이상에서 지원합니다. Windows Bridge는 Stable 또는 Beta를 동적으로 찾고, 쓰기 작업은 로컬 기능 토큰으로 보호되며 CDP는 루프백에서만 수신합니다. 저장소 설정은 npm install, npm run install:plugin, npm run setup을 실행하세요.", "Actions": [ { "Name": "Codex 작업 1", "Tooltip": "최근 Codex 작업 슬롯 1을 열고 실시간 상태를 표시합니다." }, { "Name": "Codex 작업 2", "Tooltip": "최근 Codex 작업 슬롯 2를 열고 실시간 상태를 표시합니다." }, @@ -21,7 +21,7 @@ "Localization": { "BridgeSetup": "Codex Bridge 설정", "BridgeSetupDescription": "로컬 Codex 연결을 설치하고 실행한 뒤 확인합니다.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge 서비스", "CDPConnection": "CDP 연결", "InstallRepair": "설치 / 복구", @@ -30,14 +30,14 @@ "UninstallingBridge": "제거 중…", "UninstallBridgeDone": "Codex Bridge가 제거되었습니다. Ulanzi 플러그인과 Codex Desktop은 유지됩니다.", "UninstallBridgeConfirm": "Codex Bridge와 백그라운드 서비스를 제거할까요? Ulanzi 플러그인과 Codex Desktop은 삭제되지 않습니다.", - "BridgeAppInfoTitle": "Codex Bridge.app이란?", - "BridgeAppDescription": "Codex Bridge.app은 로컬 실행기입니다. Codex가 이미 실행 중이면 정상 종료를 요청한 뒤, 로컬 루프백 전용 CDP 실행 인자로 공식 Codex Desktop 실행 파일을 시작합니다.", - "BridgeAppSafety": "이 실행기는 Codex Desktop, 계정, 프로젝트 또는 사용자 파일을 수정하지 않으며 CDP를 이 Mac 외부에 노출하지 않습니다. 그 밖의 권한 상승이나 파괴적 작업도 수행하지 않습니다. 별도의 Bridge 서비스가 로컬 상태와 Action 통신을 처리합니다.", + "BridgeAppInfoTitle": "Codex Bridge는 Codex를 어떻게 실행하나요?", + "BridgeAppDescription": "Windows에서는 Codex Bridge가 Stable 또는 Beta를 동적으로 찾아 루프백 전용 CDP 인자로 공식 앱을 실행하고, macOS에서는 로컬 래퍼 앱을 사용합니다.", + "BridgeAppSafety": "실행기는 Codex Desktop, 계정, 프로젝트 또는 사용자 파일을 수정하지 않습니다. CDP는 로컬로 유지되며 Bridge 쓰기 작업에는 로컬 기능 토큰이 필요합니다.", "BackgroundActivityTitle": "백그라운드 활동 및 Node.js", - "LaunchAgentDescription": "LaunchAgent는 로그인할 때 로컬 Codex Bridge 서비스를 시작하고 종료되면 다시 시작합니다. 서비스는 루프백 주소를 통해 이 Mac에서만 수신합니다.", + "LaunchAgentDescription": "Windows에서는 Ulanzi 플러그인이 사용자 수준 Bridge를 감독하고 macOS에서는 사용자 LaunchAgent가 감독합니다. 서비스는 루프백에서만 수신합니다.", "NodeSelectionStrategy": "설치 / 복구는 로그인 환경, PATH, Homebrew 및 일반 설치 경로의 Node.js 20 이상을 우선 선택하고, 적합한 버전이 없을 때만 Ulanzi Studio 내장 Node를 사용합니다.", "NodeInstallRecommendation": "권장: Terminal에서 최신 Node.js를 설치한 다음 설치 / 복구를 다시 실행하세요:", - "UlanziNodeSignatureNotice": "Ulanzi Studio Node로 대체하면 macOS 백그라운드 항목에 Ulanzi Studio 서명이 포함된 활동이 표시될 수 있습니다. 이는 Codex Bridge 서비스가 Ulanzi Studio의 Node 실행 파일로 동작하기 때문입니다.", + "UlanziNodeSignatureNotice": "Bridge는 현재 사용자 권한으로 실행되며 관리자 수준 Windows 서비스를 설치하지 않습니다.", "CurrentNodeRuntime": "현재 런타임: {runtime} {version}", "SystemNode": "시스템 Node.js", "UlanziNode": "Ulanzi Studio Node", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json index 74a06af..afc7709 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json @@ -2,7 +2,7 @@ "Author": "OpenCodexMicro", "Name": "Codex Micro", "Overview": "Control Codex Desktop tasks and actions from Ulanzi D200 Series in Ulanzi Studio.", - "Description": "Turn Ulanzi D200 Series into a dedicated Codex Desktop control surface through Ulanzi Studio.\n\nFEATURES\n- View five recent Codex tasks with live status and open the exact selected task.\n- Use Fast, Usage, Pin, New, Fork, Steer, Mic, and Submit actions.\n- Use the Latest Task & Scroll Encoder action to open task 1 and scroll Codex.\n- Install, repair, launch, and check Codex Bridge from every Action setup page.\n- Keep communication local through Codex Bridge.app and loopback-only endpoints.\n\nINSTALLATION ENVIRONMENT\n- macOS 13 or later\n- Codex Desktop\n- Ulanzi Studio 3.0.1 or later\n- Ulanzi D200 Series connected in Ulanzi Studio\n- Node.js 20 or later only for manual repository setup\n\nLLM / AGENT INSTALLATION\nIn your Agent, enter:\nInstall the Ulanzi Studio plugin for me: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nMANUAL INSTALLATION\n1. Clone https://github.com/UlanziTechnology/OpenCodexMicro and open the repository directory.\n2. Quit Ulanzi Studio.\n3. Run: npm install\n4. Run: npm run install:plugin\n5. Run: npm run setup\n6. Reopen Ulanzi Studio, quit Codex Desktop, and launch ~/Applications/Codex Bridge.app.\n7. Add the Codex Micro actions to your keys in Ulanzi Studio.\n8. Select any Action to check Bridge installation, service, and CDP status.\n\nIMPORTANT\nWhen using Codex Micro, always launch Codex through ~/Applications/Codex Bridge.app. Do not open Codex Desktop directly.\nLaunch command: open ~/Applications/Codex\\ Bridge.app", + "Description": "Turn Ulanzi D200 Series into a dedicated Codex Desktop control surface through Ulanzi Studio.\n\nFEATURES\n- View five recent Codex tasks with live status and open the exact selected task.\n- Use Fast, Usage, Pin, New, Fork, Steer, Mic, and Submit actions.\n- Use the Latest Task & Scroll Encoder action to open task 1 and scroll Codex.\n- Install, repair, launch, and check Codex Bridge from every Action setup page.\n- Keep Bridge and CDP communication on authenticated loopback-only endpoints.\n\nINSTALLATION ENVIRONMENT\n- Windows 10 or later, or macOS 13 or later\n- Codex Desktop Stable or Beta\n- Ulanzi Studio 3.0.1 or later\n- Ulanzi D200 Series connected in Ulanzi Studio\n- Node.js 20 or later only for manual repository setup\n\nMANUAL INSTALLATION\n1. Clone the repository and open plugins/codex-console.\n2. Quit Ulanzi Studio.\n3. Run: npm install\n4. Run: npm run install:plugin\n5. Run: npm run setup\n6. Reopen Ulanzi Studio and select any Action to check Bridge, service, and CDP status.\n\nIMPORTANT\nOn Windows, Codex Bridge dynamically locates the installed Stable or Beta Appx package and starts Codex with loopback-only CDP arguments. It does not use a version-pinned WindowsApps path.", "Icon": "assets/icons/plugin.png", "Category": "AI", "CategoryIcon": "assets/icons/plugin.png", @@ -11,7 +11,7 @@ "assets/banners/codex-micro-banner-2.png", "assets/banners/codex-micro-banner-3.png" ], - "Version": "0.4.0", + "Version": "0.5.0", "UUID": "com.ulanzi.ulanzistudio.codexmicro", "Type": "JavaScript", "CodePath": "dist/app.js", @@ -22,6 +22,10 @@ { "Platform": "mac", "MinimumVersion": "13.0" + }, + { + "Platform": "windows", + "MinimumVersion": "10.0" } ], "Actions": [ diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json index e0c483d..7fae435 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "ws": "^8.18.0" }, diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json index c2b4613..2c2c389 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json @@ -1,6 +1,6 @@ { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.4.0", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js index 7c93e76..d1d9623 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js @@ -310,6 +310,7 @@ function renderAll() { async function bridgeRequest(path, method = "GET") { const response = await fetch(`${BRIDGE_URL}${path}`, { method, + headers: await bridgeSetup.authorizationHeaders(), signal: AbortSignal.timeout(1200) }); const payload = await response.json(); @@ -451,6 +452,9 @@ function connect() { socket = new WebSocket(HOST_URL); socket.on("open", () => { send({ code: 0, cmd: "connected", uuid: PLUGIN_UUID }); + if (process.env.CODEX_BRIDGE_AUTOSTART !== "0") { + void bridgeSetup.ensureService().catch(() => {}); + } clearInterval(pollTimer); pollTimer = setInterval(() => void pollBridge(), 500); pollTimer.unref(); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js index 801203a..55ecd07 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js @@ -9,10 +9,16 @@ import { rm, writeFile } from "node:fs/promises"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { promisify } from "node:util"; import { homedir } from "node:os"; -import { delimiter, join, resolve } from "node:path"; +import { delimiter, isAbsolute, join, resolve } from "node:path"; +import { + discoverDebugPort, + focusCodex, + launchWindowsCodex +} from "../../../src/bridge/platform.mjs"; const execFileAsync = promisify(execFile); @@ -53,6 +59,17 @@ async function nodeVersion(executable, execute) { } } +export function bridgeDataRoot({ + platform = process.platform, + home = homedir(), + localAppData = process.env.LOCALAPPDATA +} = {}) { + if (platform === "win32") { + return join(localAppData || join(home, "AppData", "Local"), "OpenCodexMicro"); + } + return join(home, "Library", "Application Support", "OpenCodexMicro"); +} + export async function selectBridgeNodeRuntime({ home = homedir(), fallbackNodeExecutable = process.execPath, @@ -66,44 +83,47 @@ export async function selectBridgeNodeRuntime({ const { stdout = "" } = await execute("/bin/zsh", ["-lic", "node -p process.execPath"]); const discovered = String(stdout) .split(/\r?\n/) - .map(line => line.trim()) - .find(line => line.startsWith("/") && line.split("/").at(-1) === "node"); + .map((line) => line.trim()) + .find((line) => line.startsWith("/") && line.split("/").at(-1) === "node"); if (discovered) candidates.push(await realpath(discovered)); } catch { - // Login-shell discovery is best effort; fixed paths and fallback remain. + // Login-shell discovery is best effort; PATH and fallback remain. } } + const nodeName = platform === "win32" ? "node.exe" : "node"; for (const directory of environmentPath.split(delimiter).filter(Boolean)) { - candidates.push(join(directory, "node")); + candidates.push(join(directory, nodeName)); + } + if (platform === "darwin") { + candidates.push( + "/opt/homebrew/bin/node", + "/usr/local/bin/node", + "/usr/bin/node", + join(home, ".local", "bin", "node") + ); } - candidates.push( - "/opt/homebrew/bin/node", - "/usr/local/bin/node", - "/usr/bin/node", - join(home, ".local", "bin", "node") - ); let resolvedFallback = fallbackNodeExecutable; try { resolvedFallback = await realpath(fallbackNodeExecutable); } catch { - // The fallback availability check below will provide the final result. + // The fallback availability check below provides the final result. } for (const executable of [...new Set(candidates)]) { - if (!executable.startsWith("/")) continue; - if (!await exists(executable, fsConstants.X_OK)) continue; + if (!isAbsolute(executable)) continue; + if (!await exists(executable, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK)) continue; const resolvedExecutable = await realpath(executable); if (resolvedExecutable === resolvedFallback) continue; - const version = await nodeVersion(resolvedExecutable, execute); - if (version?.major >= 20) { - return { executable: resolvedExecutable, version: version.text, source: "system" }; + const candidateVersion = await nodeVersion(resolvedExecutable, execute); + if (candidateVersion?.major >= 20) { + return { executable: resolvedExecutable, version: candidateVersion.text, source: "system" }; } } - if (await exists(resolvedFallback, fsConstants.X_OK)) { - const version = await nodeVersion(resolvedFallback, execute); - if (version?.major >= 20) { - return { executable: resolvedFallback, version: version.text, source: "ulanzi" }; + if (await exists(resolvedFallback, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK)) { + const fallbackVersion = await nodeVersion(resolvedFallback, execute); + if (fallbackVersion?.major >= 20) { + return { executable: resolvedFallback, version: fallbackVersion.text, source: "ulanzi" }; } } throw new Error("No compatible Node.js 20 or later runtime was found."); @@ -114,13 +134,24 @@ export function createBridgeInstaller({ bridgeUrl, version, home = homedir(), + localAppData = process.env.LOCALAPPDATA, uid = process.getuid?.(), platform = process.platform, nodeExecutable = process.execPath, environmentPath = process.env.PATH || "", - execute = execFileAsync + codexChannel = process.env.CODEX_DESKTOP_CHANNEL || "stable", + execute = execFileAsync, + spawnProcess = spawn, + fetchImpl = fetch }) { - const appRoot = join(home, "Library", "Application Support", "OpenCodexMicro"); + const appRoot = bridgeDataRoot({ platform, home, localAppData }); + const bridgeRuntime = join(appRoot, "bridge.mjs"); + const tokenPath = join(appRoot, "bridge-token"); + const installMetadata = join(appRoot, "install.json"); + const installerRoot = resolve(pluginRoot, "installer"); + const bundledRuntime = join(installerRoot, "bridge.mjs"); + const bundledIcon = join(installerRoot, "CodexBridge.png"); + const userApplications = join(home, "Applications"); const bridgeApp = join(userApplications, "Codex Bridge.app"); const bridgeContents = join(bridgeApp, "Contents"); @@ -129,17 +160,23 @@ export function createBridgeInstaller({ const bridgeLicenses = join(bridgeResources, "licenses"); const bridgeExecutable = join(bridgeMacOS, "Codex Bridge"); const bridgeIcon = join(bridgeResources, "CodexBridge.icns"); - const bridgeRuntime = join(appRoot, "bridge.mjs"); - const installMetadata = join(appRoot, "install.json"); const agentsRoot = join(home, "Library", "LaunchAgents"); const bridgeAgent = join(agentsRoot, "io.opencodexmicro.bridge.plist"); - const installerRoot = resolve(pluginRoot, "installer"); - const bundledRuntime = join(installerRoot, "bridge.mjs"); - const bundledIcon = join(installerRoot, "CodexBridge.png"); + let lastWindowsServiceStart = 0; + + async function authorizationHeaders() { + try { + const token = (await readFile(tokenPath, "utf8")).trim(); + return token ? { Authorization: `Bearer ${token}` } : {}; + } catch { + return {}; + } + } async function probeBridge() { try { - const response = await fetch(`${bridgeUrl}/health`, { + const response = await fetchImpl(`${bridgeUrl}/health`, { + headers: await authorizationHeaders(), signal: AbortSignal.timeout(1200) }); const payload = await response.json(); @@ -151,50 +188,48 @@ export function createBridgeInstaller({ } async function status() { - const [appInstalled, runtimeInstalled, agentInstalled, metadata, probe] = await Promise.all([ - exists(bridgeExecutable, fsConstants.X_OK), + const [runtimeInstalled, tokenInstalled, metadata, probe] = await Promise.all([ exists(bridgeRuntime), - exists(bridgeAgent), + exists(tokenPath), readJson(installMetadata), probeBridge() ]); - const installed = appInstalled && runtimeInstalled && agentInstalled; + const macAppInstalled = platform === "darwin" + ? await exists(bridgeExecutable, fsConstants.X_OK) + : runtimeInstalled; + const agentInstalled = platform === "darwin" ? await exists(bridgeAgent) : true; + const installed = macAppInstalled && runtimeInstalled && tokenInstalled && agentInstalled; return { - supported: platform === "darwin" && Number.isInteger(uid), + supported: platform === "win32" || (platform === "darwin" && Number.isInteger(uid)), + platform, installed, - appInstalled, - serviceInstalled: runtimeInstalled && agentInstalled, + appInstalled: macAppInstalled, + serviceInstalled: runtimeInstalled && tokenInstalled && agentInstalled, installedVersion: metadata?.version || null, bundledVersion: version, needsUpdate: !installed || metadata?.version !== version, - appPath: bridgeApp, + appPath: platform === "darwin" ? bridgeApp : appRoot, nodeExecutable: metadata?.nodeExecutable || null, nodeVersion: metadata?.nodeVersion || null, nodeSource: metadata?.nodeSource || null, + codexChannel: metadata?.codexChannel || codexChannel, ...probe }; } - async function buildIcon() { + async function buildMacIcon() { const iconset = join(appRoot, "CodexBridge.iconset"); await rm(iconset, { recursive: true, force: true }); await mkdir(iconset, { recursive: true }); try { for (const [name, size] of [ - ["icon_16x16.png", 16], - ["icon_16x16@2x.png", 32], - ["icon_32x32.png", 32], - ["icon_32x32@2x.png", 64], - ["icon_128x128.png", 128], - ["icon_128x128@2x.png", 256], - ["icon_256x256.png", 256], - ["icon_256x256@2x.png", 512], - ["icon_512x512.png", 512], - ["icon_512x512@2x.png", 1024] + ["icon_16x16.png", 16], ["icon_16x16@2x.png", 32], + ["icon_32x32.png", 32], ["icon_32x32@2x.png", 64], + ["icon_128x128.png", 128], ["icon_128x128@2x.png", 256], + ["icon_256x256.png", 256], ["icon_256x256@2x.png", 512], + ["icon_512x512.png", 512], ["icon_512x512@2x.png", 1024] ]) { - await execute("/usr/bin/sips", [ - "-z", String(size), String(size), bundledIcon, "--out", join(iconset, name) - ]); + await execute("/usr/bin/sips", ["-z", String(size), String(size), bundledIcon, "--out", join(iconset, name)]); } await execute("/usr/bin/iconutil", ["-c", "icns", iconset, "-o", bridgeIcon]); } finally { @@ -202,38 +237,17 @@ export function createBridgeInstaller({ } } - async function install() { - if (platform !== "darwin" || !Number.isInteger(uid)) { - throw new Error("Codex Bridge installation is supported on macOS only."); - } - if (!await exists(bundledRuntime) || !await exists(bundledIcon)) { - throw new Error("The plugin does not contain the Codex Bridge installation resources."); - } - const nodeRuntime = await selectBridgeNodeRuntime({ - home, - fallbackNodeExecutable: nodeExecutable, - environmentPath, - platform, - execute - }); - - await mkdir(appRoot, { recursive: true, mode: 0o700 }); - await chmod(appRoot, 0o700); + async function installMac(nodeRuntime) { await mkdir(userApplications, { recursive: true }); await mkdir(agentsRoot, { recursive: true }); - await copyFile(bundledRuntime, bridgeRuntime); - await rm(bridgeApp, { recursive: true, force: true }); await mkdir(bridgeMacOS, { recursive: true }); await mkdir(bridgeLicenses, { recursive: true }); for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { - const source = join(installerRoot, notice); - await copyFile(source, join(bridgeLicenses, notice)); - await copyFile(source, join(appRoot, notice)); + await copyFile(join(installerRoot, notice), join(bridgeLicenses, notice)); } - await buildIcon(); - - const info = ` + await buildMacIcon(); + await writeFile(join(bridgeContents, "Info.plist"), ` CFBundleDisplayNameCodex Bridge @@ -246,116 +260,140 @@ export function createBridgeInstaller({ CFBundleVersion${xml(version)} LSMinimumSystemVersion13.0 LSUIElement - NSHighResolutionCapable -`; - await writeFile(join(bridgeContents, "Info.plist"), info); - - const launcher = `#!/bin/zsh +`); + await writeFile(bridgeExecutable, `#!/bin/zsh set -u -unsetopt BG_NICE - codex_binary="/Applications/ChatGPT.app/Contents/MacOS/ChatGPT" -bridge_log="$HOME/Library/Logs/OpenCodexMicro-codex-bridge.log" - -if [[ ! -x "$codex_binary" ]]; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex was not found at /Applications/ChatGPT.app." as critical' - exit 1 -fi - +if [[ ! -x "$codex_binary" ]]; then exit 1; fi if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then /usr/bin/osascript -e 'tell application id "com.openai.codex" to quit' - for attempt in {1..80}; do - /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break - /bin/sleep 0.1 - done -fi - -if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex did not quit, so bridge parameters could not be applied. Quit Codex and try again." as critical' - exit 1 + for attempt in {1..80}; do /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break; /bin/sleep 0.1; done fi - -/usr/bin/nohup "$codex_binary" \\ - --remote-debugging-address=127.0.0.1 \\ - --remote-debugging-port=9222 \\ - --remote-allow-origins=http://127.0.0.1:9222 \\ - >>"$bridge_log" 2>&1 & - -for attempt in {1..300}; do - if /usr/bin/curl --noproxy '*' --silent --fail --max-time 0.2 \\ - http://127.0.0.1:9222/json/version >/dev/null 2>&1; then - exit 0 - fi - if ! /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - break - fi - /bin/sleep 0.1 -done - -/usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex started, but the bridge endpoint is unavailable. Quit Codex and launch Codex Bridge again." as critical' -exit 1 -`; - await writeFile(bridgeExecutable, launcher, { mode: 0o755 }); +if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi +/usr/bin/nohup "$codex_binary" ${ + ["--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--remote-allow-origins=http://127.0.0.1:9222"].join(" ") +} >/dev/null 2>&1 & +`, { mode: 0o755 }); await chmod(bridgeExecutable, 0o755); await execute("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", bridgeApp]); - - const plist = ` + await writeFile(bridgeAgent, ` Labelio.opencodexmicro.bridge - ProgramArguments - ${xml(nodeRuntime.executable)} - ${xml(bridgeRuntime)} - - RunAtLoad - KeepAlive - ProcessTypeBackground - ThrottleInterval2 + ProgramArguments${xml(nodeRuntime.executable)}${xml(bridgeRuntime)} + RunAtLoadKeepAlive + ProcessTypeBackgroundThrottleInterval2 StandardOutPath${xml(join(appRoot, "bridge.log"))} StandardErrorPath${xml(join(appRoot, "bridge-error.log"))} -`; - await writeFile(bridgeAgent, plist, { mode: 0o644 }); +`, { mode: 0o644 }); + try { await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); } catch {} + await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + } + + async function install() { + if (!(platform === "win32" || (platform === "darwin" && Number.isInteger(uid)))) { + throw new Error("Codex Bridge installation is supported on Windows and macOS only."); + } + if (!await exists(bundledRuntime) || !await exists(bundledIcon)) { + throw new Error("The plugin does not contain the Codex Bridge installation resources."); + } + const nodeRuntime = await selectBridgeNodeRuntime({ + home, + fallbackNodeExecutable: nodeExecutable, + environmentPath, + platform, + execute + }); + await mkdir(appRoot, { recursive: true, mode: 0o700 }); + if (platform !== "win32") await chmod(appRoot, 0o700); + await copyFile(bundledRuntime, bridgeRuntime); + for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { + await copyFile(join(installerRoot, notice), join(appRoot, notice)); + } + if (!await exists(tokenPath)) { + await writeFile(tokenPath, `${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 }); + } await writeFile(installMetadata, `${JSON.stringify({ version, + platform, + codexChannel, nodeExecutable: nodeRuntime.executable, nodeVersion: nodeRuntime.version, nodeSource: nodeRuntime.source, installedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); + if (platform === "darwin") await installMac(nodeRuntime); + if (platform === "win32") await ensureService(); + return status(); + } - try { - await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { - // The service may not be loaded yet. + async function ensureService() { + if (platform !== "win32") return status(); + const probe = await probeBridge(); + if (probe.serviceOnline || Date.now() - lastWindowsServiceStart < 2000) return { ...await status(), ...probe }; + const metadata = await readJson(installMetadata); + if (!metadata?.nodeExecutable || !await exists(bridgeRuntime) || !await exists(tokenPath)) { + throw new Error("Codex Bridge is not installed. Use Install / Repair first."); } - await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + const token = (await readFile(tokenPath, "utf8")).trim(); + const child = spawnProcess(metadata.nodeExecutable, [bridgeRuntime], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + CODEX_BRIDGE_DATA_ROOT: appRoot, + CODEX_BRIDGE_TOKEN: token + } + }); + child.unref?.(); + lastWindowsServiceStart = Date.now(); return status(); } async function launch() { - if (!await exists(bridgeExecutable, fsConstants.X_OK)) { - throw new Error("Codex Bridge.app is not installed."); + const current = await status(); + if (!current.installed) throw new Error("Codex Bridge is not installed. Use Install / Repair first."); + if (platform === "darwin") { + await execute("/usr/bin/open", [bridgeApp]); + return status(); } - await execute("/usr/bin/open", [bridgeApp]); - return status(); + if (platform === "win32") { + await ensureService(); + try { + await discoverDebugPort({ platform, execute, fetchImpl }); + await focusCodex({ platform, execute }); + } catch (error) { + if (!/local debug bridge/.test(error.message)) throw error; + await launchWindowsCodex({ channel: codexChannel, execute, spawnProcess }); + } + return status(); + } + throw new Error(`Codex Bridge launch is not supported on ${platform}.`); } async function uninstall() { - if (platform !== "darwin" || !Number.isInteger(uid)) { - throw new Error("Codex Bridge uninstallation is supported on macOS only."); + if (!(platform === "win32" || (platform === "darwin" && Number.isInteger(uid)))) { + throw new Error("Codex Bridge uninstallation is supported on Windows and macOS only."); } - try { - await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { - // The service may already be stopped or absent. + if (platform === "darwin") { + try { await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); } catch {} + await rm(bridgeAgent, { force: true }); + await rm(bridgeApp, { recursive: true, force: true }); + } else { + const stopScript = "$target=$env:CODEX_BRIDGE_TARGET_RUNTIME; Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.Name -like 'node*' -and $_.CommandLine -like ('*' + $target + '*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"; + try { + await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", stopScript], { + timeout: 5000, + env: { ...process.env, CODEX_BRIDGE_TARGET_RUNTIME: bridgeRuntime } + }); + } catch {} } - await rm(bridgeAgent, { force: true }); await rm(appRoot, { recursive: true, force: true }); - await rm(bridgeApp, { recursive: true, force: true }); return status(); } - return { status, install, launch, uninstall }; + return { status, install, launch, uninstall, ensureService, authorizationHeaders }; } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/property-inspector/setup.html b/integration/com.ulanzi.codexmicro.ulanziPlugin/property-inspector/setup.html index 038564d..f9caef1 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/property-inspector/setup.html +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/property-inspector/setup.html @@ -9,37 +9,26 @@ html, body { margin: 0; background: transparent; color: #e8e8e8; font-size: 13px; } * { box-sizing: border-box; } main { padding: 12px 14px 10px; } - h1 { margin: 0 0 5px; font-size: 15px; font-weight: 650; } - .intro { margin: 0 0 10px; color: #aeb3ba; line-height: 1.35; } - .panel { border: 1px solid rgba(255,255,255,.12); border-radius: 8px; overflow: hidden; background: rgba(255,255,255,.035); } - .row { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: center; min-height: 37px; padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,.08); } + h1 { margin: 0 0 5px; font-size: 15px; } + .intro, .message, .info-body { color: #aeb3ba; line-height: 1.4; } + .intro { margin: 0 0 10px; } + .panel, details { border: 1px solid rgba(255,255,255,.12); border-radius: 8px; background: rgba(255,255,255,.035); } + .row { display: grid; grid-template-columns: 1fr auto; gap: 12px; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,.08); } .row:last-child { border-bottom: 0; } - .value { display: inline-flex; align-items: center; gap: 6px; color: #aeb3ba; text-align: right; } - .dot { width: 8px; height: 8px; border-radius: 50%; background: #72777f; box-shadow: 0 0 0 2px rgba(114,119,127,.15); } - .value.good { color: #75d6a3; } - .value.good .dot { background: #42c985; box-shadow: 0 0 0 2px rgba(66,201,133,.15); } - .value.warn { color: #f0bc68; } - .value.warn .dot { background: #e6a84b; box-shadow: 0 0 0 2px rgba(230,168,75,.15); } - .value.bad { color: #ef8585; } - .value.bad .dot { background: #e45f5f; box-shadow: 0 0 0 2px rgba(228,95,95,.15); } - .message { min-height: 18px; margin: 8px 1px 4px; color: #aeb3ba; line-height: 1.35; } - .message.error { color: #ef8585; } - .message.success { color: #75d6a3; } + .value { display: inline-flex; align-items: center; gap: 6px; text-align: right; } + .dot { width: 8px; height: 8px; border-radius: 50%; background: #72777f; } + .good { color: #75d6a3; } .good .dot { background: #42c985; } + .warn { color: #f0bc68; } .warn .dot { background: #e6a84b; } + .bad, .error { color: #ef8585; } .bad .dot { background: #e45f5f; } + .success { color: #75d6a3; } + .message { min-height: 18px; margin: 8px 1px 4px; } .actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 8px; } button { min-height: 33px; border: 1px solid rgba(255,255,255,.14); border-radius: 6px; color: #f3f3f3; background: rgba(255,255,255,.08); cursor: pointer; } - button:hover:not(:disabled) { background: rgba(255,255,255,.13); } - button.primary { border-color: #3f78bd; background: #2867ad; } - button.primary:hover:not(:disabled) { background: #3276bf; } - button.danger { grid-column: 1 / -1; color: #ef9a9a; border-color: rgba(228,95,95,.35); background: rgba(228,95,95,.08); } - button.danger:hover:not(:disabled) { background: rgba(228,95,95,.16); } - button:disabled { cursor: default; opacity: .45; } - details.info { margin-top: 10px; border: 1px solid rgba(255,255,255,.1); border-radius: 7px; background: rgba(255,255,255,.025); } - details.info summary { padding: 9px 10px; color: #d5d8dc; cursor: pointer; font-weight: 600; } - .info-body { padding: 0 10px 10px; color: #9fa5ad; line-height: 1.45; } - .info-body p { margin: 7px 0; } - .info-body code { display: block; margin: 7px 0; padding: 7px 8px; overflow-wrap: anywhere; border-radius: 5px; color: #dce7f6; background: rgba(0,0,0,.28); user-select: text; } - .runtime { color: #c7d3e2; } - .signature-note { color: #e3b971; } + button.primary { background: #2867ad; } button.danger { grid-column: 1 / -1; color: #ef9a9a; } + button:disabled { opacity: .45; cursor: default; } + details { margin-top: 10px; } summary { padding: 9px 10px; cursor: pointer; font-weight: 600; } + .info-body { padding: 0 10px 10px; } + .info-body code { display: block; margin: 7px 0; padding: 7px 8px; border-radius: 5px; background: rgba(0,0,0,.28); user-select: text; } .version { margin-top: 7px; color: #777e87; font-size: 11px; text-align: right; } @@ -54,13 +43,13 @@

- +
-
+

@@ -68,153 +57,94 @@

-
+

-

- brew install node -

-

+

+

diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/pt_PT.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/pt_PT.json index d62042b..86ca6a5 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/pt_PT.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/pt_PT.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "Controle tarefas e ações do Codex Desktop com a Ulanzi D200 Series no Ulanzi Studio.", - "Description": "Transforme a Ulanzi D200 Series numa superfície de controlo dedicada ao Codex Desktop através do Ulanzi Studio.\n\nFUNCIONALIDADES\n- Mostra cinco tarefas recentes do Codex com estado em tempo real e abre exatamente a tarefa selecionada.\n- Disponibiliza as ações Fast, Usage, Pin, New, Fork, Steer, Mic e Submit.\n- Use o Encoder Latest Task & Scroll para abrir a task1 e deslocar o Codex.\n- Instale, repare, inicie e verifique o Codex Bridge na página de configuração de cada Action.\n- Mantém a comunicação local através da Codex Bridge.app e de endpoints apenas de loopback.\n\nAMBIENTE DE INSTALAÇÃO\n- macOS 13 ou posterior\n- Codex Desktop\n- Ulanzi Studio 3.0.1 ou posterior\n- Ulanzi D200 Series ligada no Ulanzi Studio\n- Node.js 20 ou posterior apenas para configuração manual pelo repositório\n\nINSTALAÇÃO COM LLM / AGENT\nIntroduza no seu Agent:\nInstala o plugin do Ulanzi Studio por mim: [https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\nINSTALAÇÃO MANUAL\n1. Clone https://github.com/UlanziTechnology/OpenCodexMicro e abra a pasta do repositório.\n2. Feche o Ulanzi Studio.\n3. Execute: npm install\n4. Execute: npm run install:plugin\n5. Execute: npm run setup\n6. Reabra o Ulanzi Studio, feche o Codex Desktop e inicie ~/Applications/Codex Bridge.app.\n7. Adicione as ações Codex Micro às teclas pretendidas no Ulanzi Studio.\n8. Selecione uma Action e verifique a instalação, o serviço e o CDP na página do Codex Bridge.\n\nIMPORTANTE\nAo usar o Codex Micro, inicie sempre o Codex através de ~/Applications/Codex Bridge.app. Não abra diretamente o Codex Desktop.\nComando de início: open ~/Applications/Codex\\ Bridge.app", + "Description": "Controle o Codex Desktop com a Ulanzi D200 Series no Ulanzi Studio. Suporta cinco tarefas recentes, Fast, Usage, Pin, New, Fork, Steer, Mic, Submit e navegação por Encoder no Windows 10 ou posterior e macOS 13 ou posterior. No Windows, a Bridge encontra Stable ou Beta dinamicamente; as ações de escrita usam um token local e o CDP permanece em loopback. Para configurar pelo repositório execute npm install, npm run install:plugin e npm run setup.", "Actions": [ { "Name": "Tarefa Codex 1", "Tooltip": "Abre a posição 1 das tarefas recentes do Codex e mostra o estado em tempo real." }, { "Name": "Tarefa Codex 2", "Tooltip": "Abre a posição 2 das tarefas recentes do Codex e mostra o estado em tempo real." }, @@ -21,7 +21,7 @@ "Localization": { "BridgeSetup": "Configuração do Codex Bridge", "BridgeSetupDescription": "Instale, inicie e verifique a ligação local ao Codex.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Serviço Bridge", "CDPConnection": "Ligação CDP", "InstallRepair": "Instalar / Reparar", @@ -30,14 +30,14 @@ "UninstallingBridge": "A desinstalar…", "UninstallBridgeDone": "O Codex Bridge foi desinstalado. O plugin Ulanzi e o Codex Desktop foram mantidos.", "UninstallBridgeConfirm": "Desinstalar o Codex Bridge e o respetivo serviço em segundo plano? O plugin Ulanzi e o Codex Desktop não serão removidos.", - "BridgeAppInfoTitle": "O que é a Codex Bridge.app?", - "BridgeAppDescription": "A Codex Bridge.app é um iniciador local. Se o Codex já estiver aberto, pede-lhe que termine normalmente e inicia o executável oficial do Codex Desktop com argumentos CDP limitados ao endereço de loopback.", - "BridgeAppSafety": "O iniciador não altera o Codex Desktop, contas, projetos ou ficheiros do utilizador e não expõe o CDP fora deste Mac. Não executa outras operações privilegiadas ou destrutivas. O serviço Bridge separado trata do estado local e da comunicação com as Actions.", + "BridgeAppInfoTitle": "Como é que o Codex Bridge inicia o Codex?", + "BridgeAppDescription": "No Windows, o Codex Bridge encontra Stable ou Beta dinamicamente e inicia a aplicação oficial com argumentos CDP de loopback; no macOS usa a aplicação wrapper local.", + "BridgeAppSafety": "O iniciador não altera o Codex Desktop, contas, projetos ou ficheiros do utilizador. O CDP permanece local e as ações de escrita da Bridge exigem um token local.", "BackgroundActivityTitle": "Atividade em segundo plano e Node.js", - "LaunchAgentDescription": "O LaunchAgent inicia o serviço local Codex Bridge ao iniciar sessão e reinicia-o se terminar. O serviço escuta apenas neste Mac através de um endereço de loopback.", + "LaunchAgentDescription": "No Windows, o plugin Ulanzi supervisiona a Bridge ao nível do utilizador; no macOS, um LaunchAgent do utilizador. O serviço escuta apenas em loopback.", "NodeSelectionStrategy": "Instalar / Reparar dá prioridade ao Node.js 20 ou posterior do ambiente de início de sessão, PATH, Homebrew ou caminhos comuns. Só usa o Node incluído no Ulanzi Studio como alternativa.", "NodeInstallRecommendation": "Recomendado: instale a versão mais recente do Node.js no Terminal e execute novamente Instalar / Reparar:", - "UlanziNodeSignatureNotice": "Ao usar o Node do Ulanzi Studio como alternativa, os Itens em segundo plano do macOS podem mostrar uma atividade com a assinatura do Ulanzi Studio. É o serviço Codex Bridge a usar o executável Node do Ulanzi Studio.", + "UlanziNodeSignatureNotice": "A Bridge é executada com permissões do utilizador e não instala um serviço Windows de administrador.", "CurrentNodeRuntime": "Runtime atual: {runtime} {version}", "SystemNode": "Node.js do sistema", "UlanziNode": "Node do Ulanzi Studio", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs index d0e09e2..554c0f8 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs @@ -11,9 +11,10 @@ const navigateAction = manifest.Actions.find(action => ); assert.deepEqual(navigateAction?.Controllers, ["Encoder"]); assert.equal(navigateAction?.Encoder?.layout, "$UA1"); -assert.equal(manifest.Version, "0.4.0"); +assert.equal(manifest.Version, "0.5.0"); assert.equal(manifest.Software?.MinVersion, "3.0.1"); assert.equal(manifest.OS?.find(item => item.Platform === "mac")?.MinimumVersion, "13.0"); +assert.equal(manifest.OS?.find(item => item.Platform === "windows")?.MinimumVersion, "10.0"); assert.ok( manifest.Actions.every(action => action.PropertyInspectorPath === "property-inspector/setup.html"), "every action must expose the shared Bridge setup inspector" @@ -34,17 +35,15 @@ for (const asset of [ ]) { assert.ok((await readFile(new URL(asset, packageRootUrl))).length > 0, `${asset} must be bundled`); } +for (const banner of manifest.Banner || []) { + assert.ok((await readFile(new URL(banner, packageRootUrl))).length > 0, `${banner} must be bundled`); +} assert.match(manifest.Overview, /Ulanzi D200 Series/); -assert.match(manifest.Description, /INSTALLATION ENVIRONMENT/); -assert.match(manifest.Description, /LLM \/ AGENT INSTALLATION/); assert.match(manifest.Description, /MANUAL INSTALLATION/); assert.match(manifest.Description, /Latest Task & Scroll Encoder/); -assert.match( - manifest.Description, - /Install the Ulanzi Studio plugin for me: \[https:\/\/github\.com\/UlanziTechnology\/OpenCodexMicro#1-llm--agent-installation\]\(https:\/\/github\.com\/UlanziTechnology\/OpenCodexMicro#1-llm--agent-installation\)/ -); -assert.match(manifest.Description, /always launch Codex through ~\/Applications\/Codex Bridge\.app/i); -assert.match(manifest.Description, /open ~\/Applications\/Codex\\ Bridge\.app$/); +assert.match(manifest.Description, /Windows 10/); +assert.match(manifest.Description, /Stable or Beta/); +assert.match(manifest.Description, /does not use a version-pinned WindowsApps path/i); const localizedActionNames = { "en.json": ["Codex Task 1", "Latest Task & Scroll", "Submit to Codex"], @@ -57,21 +56,6 @@ const localizedActionNames = { "es_ES.json": ["Tarea Codex 1", "Tarea reciente y desplazamiento", "Enviar a Codex"] }; -const localizedInstallPrompts = { - "en.json": "Install the Ulanzi Studio plugin for me:", - "zh_CN.json": "替我安装一下UlanziStudio插件:", - "zh_HK.json": "請替我安裝 Ulanzi Studio 外掛程式:", - "ja_JP.json": "Ulanzi Studio プラグインをインストールしてください:", - "de_DE.json": "Installiere bitte das Ulanzi-Studio-Plugin für mich:", - "ko_KR.json": "Ulanzi Studio 플러그인을 설치해 주세요:", - "pt_PT.json": "Instala o plugin do Ulanzi Studio por mim:", - "es_ES.json": "Instálame el plugin de Ulanzi Studio:" -}; - -const llmInstallationLink = - "[https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation]" + - "(https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)"; - for (const locale of [ "en.json", "zh_CN.json", @@ -85,10 +69,9 @@ for (const locale of [ const messages = JSON.parse(await readFile(new URL(locale, packageRootUrl))); assert.equal(messages.Name, "Codex Micro", `${locale} must localize Name`); assert.ok(messages.Overview?.length > 20, `${locale} must localize Overview`); - assert.match(messages.Description, /https:\/\/github\.com\/UlanziTechnology\/OpenCodexMicro/); assert.match(messages.Description, /npm run install:plugin/); assert.match(messages.Description, /npm run setup/); - assert.match(messages.Description, /Latest Task & Scroll/); + assert.doesNotMatch(messages.Description, /Codex Bridge\.app/); assert.ok(messages.Localization?.BridgeSetup?.length > 0, `${locale} must localize the setup page`); assert.ok(messages.Localization?.InstallRepair?.length > 0, `${locale} must localize Bridge installation`); assert.ok(messages.Localization?.UninstallBridge?.length > 0, `${locale} must localize Bridge uninstallation`); @@ -96,12 +79,6 @@ for (const locale of [ assert.ok(messages.Localization?.BackgroundActivityTitle?.length > 0, `${locale} must explain background activity`); assert.ok(messages.Localization?.NodeSelectionStrategy?.length > 0, `${locale} must explain Node selection`); assert.ok(messages.Localization?.UlanziNodeSignatureNotice?.length > 0, `${locale} must explain the Ulanzi Node signature`); - assert.ok( - messages.Description.includes(`${localizedInstallPrompts[locale]}${ - locale.startsWith("zh_") || locale === "ja_JP.json" ? "" : " " - }${llmInstallationLink}`), - `${locale} must localize the anchored LLM installation prompt` - ); assert.equal(messages.Actions?.length, manifest.Actions.length, `${locale} must localize every action`); assert.ok( messages.Actions.every(action => action.Name?.length > 0 && action.Tooltip?.length > 0), @@ -112,11 +89,8 @@ for (const locale of [ localizedActionNames[locale], `${locale} action localization must follow manifest action order` ); - assert.match( - messages.Description.split("\n\n").at(-1), - /open ~\/Applications\/Codex\\ Bridge\.app$/, - `${locale} must end with the Bridge launch command` - ); + assert.equal(messages.Localization.BridgeApp, "Codex Bridge"); + assert.doesNotMatch(messages.Localization.BridgeAppDescription, /Codex Bridge\.app/); } const bridgeRequests = []; @@ -155,7 +129,11 @@ const nodeBinary = process.env.PLUGIN_NODE_BINARY || process.execPath; const pluginRoot = process.env.PLUGIN_RUNTIME_ROOT || new URL("..", import.meta.url); const child = spawn(nodeBinary, ["dist/app.js", "127.0.0.1", String(hostPort), "en-US", "3.0.0"], { cwd: pluginRoot, - env: { ...process.env, CODEX_BRIDGE_URL: `http://127.0.0.1:${bridgePort}` }, + env: { + ...process.env, + CODEX_BRIDGE_URL: `http://127.0.0.1:${bridgePort}`, + CODEX_BRIDGE_AUTOSTART: "0" + }, stdio: ["ignore", "pipe", "pipe"] }); @@ -184,7 +162,7 @@ try { ); assert.equal(setupStatus?.payload?.status?.serviceOnline, true); assert.equal(setupStatus?.payload?.status?.cdpConnected, true); - assert.equal(setupStatus?.payload?.status?.bundledVersion, "0.4.0"); + assert.equal(setupStatus?.payload?.status?.bundledVersion, "0.5.0"); const taskPaths = [ "assets/icons/task-working.png", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json index e055a1f..6aa0a7a 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "通过 Ulanzi Studio,在 Ulanzi D200 Series 上查看并控制 Codex Desktop。", - "Description": "通过 Ulanzi Studio,将 Ulanzi D200 Series 变成 Codex Desktop 专用控制台。\n\n功能\n- 实时显示最近五个 Codex 任务的状态,并精确打开所选任务。\n- 提供 Fast、Usage、Pin、New、Fork、Steer、Mic 和 Submit 操作。\n- 使用 Latest Task & Scroll 旋钮打开 task1,并上下滚动 Codex。\n- 通过每个 Action 的配置页安装、修复、启动并检测 Codex Bridge。\n- 通过 Codex Bridge.app 和仅限回环地址的接口保持本机通信。\n\n安装环境\n- macOS 13 或更高版本\n- Codex Desktop\n- Ulanzi Studio 3.0.1 或更高版本\n- 已在 Ulanzi Studio 中连接 Ulanzi D200 Series\n- 仅使用仓库手动设置 Bridge 时需要 Node.js 20 或更高版本\n\nLLM / AGENT 安装\n在 Agent 中输入:\n替我安装一下UlanziStudio插件:[https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\n手动安装\n1. 克隆 https://github.com/UlanziTechnology/OpenCodexMicro 并进入仓库目录。\n2. 退出 Ulanzi Studio。\n3. 运行:npm install\n4. 运行:npm run install:plugin\n5. 运行:npm run setup\n6. 重新打开 Ulanzi Studio,退出 Codex Desktop,再启动 ~/Applications/Codex Bridge.app。\n7. 在 Ulanzi Studio 中把 Codex Micro actions 添加到按键。\n8. 选中任意 Action,在 Codex Bridge 设置页检查安装、服务和 CDP 状态。\n\n注意事项\n使用 Codex Micro 时,必须通过 ~/Applications/Codex Bridge.app 启动 Codex,请勿直接打开 Codex Desktop。\n启动命令: open ~/Applications/Codex\\ Bridge.app", + "Description": "通过 Ulanzi Studio,在 Ulanzi D200 Series 上查看和控制 Codex Desktop。支持最近五个任务、Fast、Usage、Pin、New、Fork、Steer、Mic、Submit 和旋钮导航。支持 Windows 10 或更高版本以及 macOS 13 或更高版本。Windows 版 Bridge 会动态查找 Stable 或 Beta,Bridge 写操作使用本机能力令牌,CDP 仅监听回环地址。仓库安装请运行 npm install、npm run install:plugin 和 npm run setup。", "Actions": [ { "Name": "Codex 任务 1", "Tooltip": "打开最近的 Codex 任务槽位 1,并显示其实时状态。" }, { "Name": "Codex 任务 2", "Tooltip": "打开最近的 Codex 任务槽位 2,并显示其实时状态。" }, @@ -21,7 +21,7 @@ "Localization": { "BridgeSetup": "Codex Bridge 设置", "BridgeSetupDescription": "安装、启动并检查本机 Codex 连接。", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge 后台服务", "CDPConnection": "CDP 连接", "InstallRepair": "安装 / 修复", @@ -30,14 +30,14 @@ "UninstallingBridge": "正在卸载…", "UninstallBridgeDone": "Codex Bridge 已卸载,Ulanzi 插件和 Codex Desktop 保留不变。", "UninstallBridgeConfirm": "是否卸载 Codex Bridge 及其后台服务?不会删除 Ulanzi 插件和 Codex Desktop。", - "BridgeAppInfoTitle": "什么是 Codex Bridge.app?", - "BridgeAppDescription": "Codex Bridge.app 是一个本地启动器。如果 Codex 已在运行,它会先请求 Codex 正常退出,再使用仅限本机回环地址的 CDP 启动参数打开官方 Codex Desktop 可执行文件。", - "BridgeAppSafety": "该启动器不会修改 Codex Desktop、账户、项目或用户文件,也不会把 CDP 开放到本机之外;它不执行其他提权或破坏性操作。独立的 Bridge 服务负责本地状态和 Action 通信。", + "BridgeAppInfoTitle": "Codex Bridge 如何启动 Codex?", + "BridgeAppDescription": "Windows 上会动态查找 Stable 或 Beta,并使用仅限回环地址的 CDP 参数启动官方程序;macOS 上使用本地包装应用。", + "BridgeAppSafety": "启动器不会修改 Codex Desktop、账户、项目或用户文件。CDP 仅限本机,Bridge 写操作需要本机能力令牌。", "BackgroundActivityTitle": "后台活动与 Node.js", - "LaunchAgentDescription": "LaunchAgent 会在用户登录时启动本机 Codex Bridge 服务,并在服务退出后自动重启。该服务只通过回环地址在本机监听,不向外部网络开放。", - "NodeSelectionStrategy": "“安装 / 修复”会优先从登录环境、PATH、Homebrew 和常见安装路径选择系统 Node.js 20 或更高版本,仅在找不到合适版本时使用 Ulanzi Studio 内置 Node。", - "NodeInstallRecommendation": "建议通过 Terminal 安装最新 Node.js,然后再次执行“安装 / 修复”:", - "UlanziNodeSignatureNotice": "如果降级使用 Ulanzi Studio Node,macOS“后台项目”中可能显示一个携带 Ulanzi Studio 签名的活动。这是 Codex Bridge 服务正在使用 Ulanzi Studio 的 Node 可执行文件。", + "LaunchAgentDescription": "Windows 由 Ulanzi 插件监督用户级 Bridge 进程,macOS 由用户 LaunchAgent 监督;服务只监听回环地址。", + "NodeSelectionStrategy": "“安装 / 修复”优先选择 PATH 中的 Node.js 20 或更高版本,找不到时使用 Ulanzi Studio 内置 Node。", + "NodeInstallRecommendation": "从仓库安装时,请确认 Node.js 20 或更高版本可用:", + "UlanziNodeSignatureNotice": "Bridge 以当前用户权限运行,不安装管理员级 Windows 服务。", "CurrentNodeRuntime": "当前运行环境:{runtime} {version}", "SystemNode": "系统 Node.js", "UlanziNode": "Ulanzi Studio Node", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_HK.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_HK.json index c3c21e1..d7752b4 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_HK.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_HK.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "透過 Ulanzi Studio,在 Ulanzi D200 Series 上查看並控制 Codex Desktop。", - "Description": "透過 Ulanzi Studio,將 Ulanzi D200 Series 變成 Codex Desktop 專用控制台。\n\n功能\n- 即時顯示最近五個 Codex 任務的狀態,並準確開啟所選任務。\n- 提供 Fast、Usage、Pin、New、Fork、Steer、Mic 和 Submit 操作。\n- 使用 Latest Task & Scroll 旋鈕開啟 task1,並上下捲動 Codex。\n- 透過每個 Action 的設定頁安裝、修復、啟動並檢查 Codex Bridge。\n- 透過 Codex Bridge.app 及僅限迴環位址的介面維持本機通訊。\n\n安裝環境\n- macOS 13 或更新版本\n- Codex Desktop\n- Ulanzi Studio 3.0.1 或更新版本\n- 已在 Ulanzi Studio 中連接 Ulanzi D200 Series\n- 僅使用儲存庫手動設定 Bridge 時需要 Node.js 20 或更新版本\n\nLLM / AGENT 安裝\n在 Agent 中輸入:\n請替我安裝 Ulanzi Studio 外掛程式:[https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation](https://github.com/UlanziTechnology/OpenCodexMicro#1-llm--agent-installation)\n\n手動安裝\n1. 複製 https://github.com/UlanziTechnology/OpenCodexMicro 並進入儲存庫目錄。\n2. 結束 Ulanzi Studio。\n3. 執行:npm install\n4. 執行:npm run install:plugin\n5. 執行:npm run setup\n6. 重新開啟 Ulanzi Studio,結束 Codex Desktop,再啟動 ~/Applications/Codex Bridge.app。\n7. 在 Ulanzi Studio 中將 Codex Micro actions 加到按鍵。\n8. 選取任意 Action,在 Codex Bridge 設定頁檢查安裝、服務和 CDP 狀態。\n\n注意事項\n使用 Codex Micro 時,必須透過 ~/Applications/Codex Bridge.app 啟動 Codex,請勿直接開啟 Codex Desktop。\n啟動指令: open ~/Applications/Codex\\ Bridge.app", + "Description": "透過 Ulanzi Studio,在 Ulanzi D200 Series 上查看及控制 Codex Desktop。支援最近五個任務、Fast、Usage、Pin、New、Fork、Steer、Mic、Submit 及旋鈕導覽。支援 Windows 10 或更新版本及 macOS 13 或更新版本。Windows Bridge 會動態尋找 Stable 或 Beta,Bridge 寫入操作使用本機能力權杖,CDP 只監聽迴環位址。儲存庫安裝請執行 npm install、npm run install:plugin 及 npm run setup。", "Actions": [ { "Name": "Codex 任務 1", "Tooltip": "開啟最近的 Codex 任務欄位 1,並顯示即時狀態。" }, { "Name": "Codex 任務 2", "Tooltip": "開啟最近的 Codex 任務欄位 2,並顯示即時狀態。" }, @@ -21,7 +21,7 @@ "Localization": { "BridgeSetup": "Codex Bridge 設定", "BridgeSetupDescription": "安裝、啟動並檢查本機 Codex 連線。", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge 背景服務", "CDPConnection": "CDP 連線", "InstallRepair": "安裝 / 修復", @@ -30,14 +30,14 @@ "UninstallingBridge": "正在卸載…", "UninstallBridgeDone": "Codex Bridge 已卸載,Ulanzi 插件和 Codex Desktop 保留不變。", "UninstallBridgeConfirm": "是否卸載 Codex Bridge 及其背景服務?不會刪除 Ulanzi 插件和 Codex Desktop。", - "BridgeAppInfoTitle": "甚麼是 Codex Bridge.app?", - "BridgeAppDescription": "Codex Bridge.app 是本機啟動器。如果 Codex 已在執行,它會先要求 Codex 正常結束,再使用僅限本機迴環位址的 CDP 啟動參數開啟官方 Codex Desktop 執行檔。", - "BridgeAppSafety": "此啟動器不會修改 Codex Desktop、帳戶、專案或使用者檔案,也不會向本機以外開放 CDP;它不執行其他提權或破壞性操作。獨立 Bridge 服務負責本機狀態及 Action 通訊。", + "BridgeAppInfoTitle": "Codex Bridge 如何啟動 Codex?", + "BridgeAppDescription": "Windows 會動態尋找 Stable 或 Beta,並以僅限迴環位址的 CDP 參數啟動官方程式;macOS 使用本機包裝應用程式。", + "BridgeAppSafety": "啟動器不會修改 Codex Desktop、帳戶、專案或使用者檔案。CDP 只限本機,Bridge 寫入操作需要本機能力權杖。", "BackgroundActivityTitle": "背景活動與 Node.js", - "LaunchAgentDescription": "LaunchAgent 會在使用者登入時啟動本機 Codex Bridge 服務,並在服務結束後自動重新啟動。服務只透過迴環位址在本機監聽。", + "LaunchAgentDescription": "Windows 由 Ulanzi 插件監督使用者級 Bridge 程序,macOS 由使用者 LaunchAgent 監督;服務只監聽迴環位址。", "NodeSelectionStrategy": "「安裝 / 修復」會優先從登入環境、PATH、Homebrew 及常見安裝路徑選擇系統 Node.js 20 或以上版本,找不到合適版本時才使用 Ulanzi Studio 內置 Node。", "NodeInstallRecommendation": "建議透過 Terminal 安裝最新 Node.js,然後再次執行「安裝 / 修復」:", - "UlanziNodeSignatureNotice": "若使用 Ulanzi Studio Node 後備方案,macOS「背景項目」可能顯示帶有 Ulanzi Studio 簽名的活動。這是 Codex Bridge 服務使用 Ulanzi Studio Node 執行檔所致。", + "UlanziNodeSignatureNotice": "Bridge 以目前使用者權限執行,不安裝管理員級 Windows 服務。", "CurrentNodeRuntime": "目前執行環境:{runtime} {version}", "SystemNode": "系統 Node.js", "UlanziNode": "Ulanzi Studio Node", diff --git a/package-lock.json b/package-lock.json index e9f9590..1368a2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-codex-micro", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-codex-micro", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "devDependencies": { "esbuild": "^0.25.12", diff --git a/package.json b/package.json index baad80d..b9cf5b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-codex-micro", - "version": "0.4.0", + "version": "0.5.0", "private": true, "type": "module", "description": "Control Codex Desktop from Ulanzi Studio through a local bridge", @@ -12,12 +12,12 @@ "test": "node --test test/*_test.mjs", "build:bridge": "node scripts/build-bridge.mjs", "build:plugin": "npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run build", - "check:node": "node --check scripts/install.mjs && node --check scripts/install-plugin.mjs && node --check scripts/uninstall.mjs && node --check scripts/build-bridge.mjs && node --check src/bridge/thread-key.mjs && node --check src/bridge/codex-cdp.mjs && node --check src/bridge/server.mjs", + "check:node": "node --check scripts/install.mjs && node --check scripts/install-plugin.mjs && node --check scripts/launch.mjs && node --check scripts/uninstall.mjs && node --check scripts/build-bridge.mjs && node --check src/bridge/thread-key.mjs && node --check src/bridge/auth.mjs && node --check src/bridge/platform.mjs && node --check src/bridge/codex-cdp.mjs && node --check src/bridge/server.mjs", "check:plugin": "npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run check && npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run smoke", "check": "npm run test && npm run check:node && npm run build:bridge && npm run build:plugin && npm run check:plugin", "setup": "node scripts/install.mjs", "install:plugin": "node scripts/install-plugin.mjs", - "bridge:start": "open \"$HOME/Applications/Codex Bridge.app\"", + "bridge:start": "node scripts/launch.mjs", "uninstall": "node scripts/uninstall.mjs" }, "devDependencies": { diff --git a/scripts/install-plugin.mjs b/scripts/install-plugin.mjs index 329b881..4e0e3e0 100644 --- a/scripts/install-plugin.mjs +++ b/scripts/install-plugin.mjs @@ -23,14 +23,9 @@ const localizationFiles = [ "es_ES.json" ]; const pluginName = basename(source); -const pluginsRoot = join( - homedir(), - "Library", - "Application Support", - "Ulanzi", - "UlanziDeck", - "Plugins" -); +const pluginsRoot = process.platform === "win32" + ? join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Ulanzi", "UlanziDeck", "Plugins") + : join(homedir(), "Library", "Application Support", "Ulanzi", "UlanziDeck", "Plugins"); const destination = join(pluginsRoot, pluginName); const staging = join(pluginsRoot, `.${pluginName}.installing-${process.pid}`); const backup = join(pluginsRoot, `.${pluginName}.backup-${process.pid}`); @@ -59,6 +54,11 @@ for (const action of manifest.Actions || []) { throw new Error(`Action PropertyInspectorPath is missing: ${action.PropertyInspectorPath}`); } } +for (const banner of manifest.Banner || []) { + if (!await exists(join(source, banner))) { + throw new Error(`Manifest Banner is missing: ${banner}`); + } +} for (const relative of [ "installer/bridge.mjs", "installer/CodexBridge.png", @@ -88,6 +88,7 @@ for (const relative of [ "manifest.json", ...localizationFiles, "assets/icons", + "assets/banners", "dist", "installer", "property-inspector" diff --git a/scripts/install.mjs b/scripts/install.mjs index ffbf3f1..57ad3b5 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -1,197 +1,27 @@ -import { - chmod, - cp, - mkdir, - readFile, - rm, - writeFile -} from "node:fs/promises"; import { execFileSync } from "node:child_process"; -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { createBridgeInstaller } from "../integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js"; -const home = homedir(); -const uid = process.getuid(); +const pluginRoot = resolve("integration/com.ulanzi.codexmicro.ulanziPlugin"); const packageMetadata = JSON.parse(await readFile(resolve("package.json"), "utf8")); -const releaseVersion = String(packageMetadata.version); const start = !process.argv.slice(2).includes("--no-start"); -const appRoot = join(home, "Library", "Application Support", "OpenCodexMicro"); -const userApplications = join(home, "Applications"); -const bridgeApp = join(userApplications, "Codex Bridge.app"); -const agentsRoot = join(home, "Library", "LaunchAgents"); -const bridgeAgent = join( - agentsRoot, - "io.opencodexmicro.bridge.plist" -); -const xml = (value) => String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -await mkdir(appRoot, { recursive: true, mode: 0o700 }); -await chmod(appRoot, 0o700); -await mkdir(agentsRoot, { recursive: true }); -await mkdir(userApplications, { recursive: true }); -execFileSync(process.execPath, [resolve("scripts/build-bridge.mjs")], { +execFileSync(process.execPath, [resolve(pluginRoot, "build.mjs")], { + cwd: pluginRoot, stdio: "inherit" }); -await cp(resolve("dist/bridge.mjs"), join(appRoot, "bridge.mjs")); -const bridgeContents = join(bridgeApp, "Contents"); -const bridgeMacOS = join(bridgeContents, "MacOS"); -const bridgeResources = join(bridgeContents, "Resources"); -const bridgeLicenses = join(bridgeResources, "licenses"); -const bridgeExecutable = join(bridgeMacOS, "Codex Bridge"); -const bridgeIcon = join(bridgeResources, "CodexBridge.icns"); -const iconset = join(appRoot, "CodexBridge.iconset"); -await rm(bridgeApp, { recursive: true, force: true }); -await rm(iconset, { recursive: true, force: true }); -await mkdir(bridgeMacOS, { recursive: true }); -await mkdir(bridgeResources, { recursive: true }); -await mkdir(bridgeLicenses, { recursive: true }); -await mkdir(iconset, { recursive: true }); -for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { - await cp(resolve(notice), join(bridgeLicenses, notice)); - await cp(resolve(notice), join(appRoot, notice)); -} - -const iconSource = resolve("bridge/CodexBridge.png"); -for (const [name, size] of [ - ["icon_16x16.png", 16], - ["icon_16x16@2x.png", 32], - ["icon_32x32.png", 32], - ["icon_32x32@2x.png", 64], - ["icon_128x128.png", 128], - ["icon_128x128@2x.png", 256], - ["icon_256x256.png", 256], - ["icon_256x256@2x.png", 512], - ["icon_512x512.png", 512], - ["icon_512x512@2x.png", 1024] -]) { - execFileSync("/usr/bin/sips", [ - "-z", - String(size), - String(size), - iconSource, - "--out", - join(iconset, name) - ], { stdio: "ignore" }); -} -execFileSync("/usr/bin/iconutil", [ - "-c", - "icns", - iconset, - "-o", - bridgeIcon -]); -await rm(iconset, { recursive: true, force: true }); - -const bridgeInfo = ` - - - CFBundleDisplayNameCodex Bridge - CFBundleExecutableCodex Bridge - CFBundleIconFileCodexBridge - CFBundleIdentifierio.opencodexmicro.bridge - CFBundleNameCodex Bridge - CFBundlePackageTypeAPPL - CFBundleShortVersionString${xml(releaseVersion)} - CFBundleVersion${xml(releaseVersion)} - LSMinimumSystemVersion13.0 - LSUIElement - NSHighResolutionCapable - -`; -await writeFile(join(bridgeContents, "Info.plist"), bridgeInfo); - -const bridgeLauncher = `#!/bin/zsh -set -u -unsetopt BG_NICE - -codex_binary="/Applications/ChatGPT.app/Contents/MacOS/ChatGPT" -bridge_log="$HOME/Library/Logs/OpenCodexMicro-codex-bridge.log" - -if [[ ! -x "$codex_binary" ]]; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex was not found at /Applications/ChatGPT.app." as critical' - exit 1 -fi - -if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - /usr/bin/osascript -e 'tell application id "com.openai.codex" to quit' - for attempt in {1..80}; do - /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break - /bin/sleep 0.1 - done -fi - -if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex did not quit, so bridge parameters could not be applied. Quit Codex and try again." as critical' - exit 1 -fi - -/usr/bin/nohup "$codex_binary" \\ - --remote-debugging-address=127.0.0.1 \\ - --remote-debugging-port=9222 \\ - --remote-allow-origins=http://127.0.0.1:9222 \\ - >>"$bridge_log" 2>&1 & - -for attempt in {1..300}; do - if /usr/bin/curl --noproxy '*' --silent --fail --max-time 0.2 \\ - http://127.0.0.1:9222/json/version >/dev/null 2>&1; then - exit 0 - fi - if ! /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - break - fi - /bin/sleep 0.1 -done - -/usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex started, but the bridge endpoint is unavailable. Task keys will use the slower native fallback. Quit Codex and launch Codex Bridge again." as critical' -exit 1 -`; -await writeFile(bridgeExecutable, bridgeLauncher, { mode: 0o755 }); -await chmod(bridgeExecutable, 0o755); -execFileSync("/usr/bin/codesign", [ - "--force", - "--deep", - "--sign", - "-", - bridgeApp -], { stdio: "ignore" }); -console.log(`Codex Bridge app installed at: ${bridgeApp}`); - -const bridgePlist = ` - - - Labelio.opencodexmicro.bridge - ProgramArguments - ${xml(process.execPath)} - ${xml(join(appRoot, "bridge.mjs"))} - - RunAtLoad - KeepAlive - ProcessTypeBackground - ThrottleInterval2 - StandardOutPath${xml(join(appRoot, "bridge.log"))} - StandardErrorPath${xml(join(appRoot, "bridge-error.log"))} - -`; -await writeFile(bridgeAgent, bridgePlist, { mode: 0o644 }); - -for (const agent of [bridgeAgent]) { - try { - execFileSync("/bin/launchctl", ["bootout", `gui/${uid}`, agent], { - stdio: "ignore" - }); - } catch { - // The service may not be installed. - } -} +const installer = createBridgeInstaller({ + pluginRoot, + bridgeUrl: process.env.CODEX_BRIDGE_URL || "http://127.0.0.1:17373", + version: String(packageMetadata.version) +}); +const installed = await installer.install(); +console.log(`Codex Bridge installed at: ${installed.appPath}`); if (start) { - execFileSync("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); - console.log("Codex Bridge installed and started."); + await installer.launch(); + console.log("Codex Bridge and Codex Desktop launch requested."); } else { - console.log("Codex Bridge installed; daemon start skipped."); + console.log("Codex Bridge installed; launch skipped."); } diff --git a/scripts/launch.mjs b/scripts/launch.mjs new file mode 100644 index 0000000..b0bbfc3 --- /dev/null +++ b/scripts/launch.mjs @@ -0,0 +1,13 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { createBridgeInstaller } from "../integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js"; + +const pluginRoot = resolve("integration/com.ulanzi.codexmicro.ulanziPlugin"); +const packageMetadata = JSON.parse(await readFile(resolve("package.json"), "utf8")); +const installer = createBridgeInstaller({ + pluginRoot, + bridgeUrl: process.env.CODEX_BRIDGE_URL || "http://127.0.0.1:17373", + version: String(packageMetadata.version) +}); +await installer.launch(); +console.log("Codex Bridge and Codex Desktop launch requested."); diff --git a/scripts/uninstall.mjs b/scripts/uninstall.mjs index f2e8761..c2a5cc6 100644 --- a/scripts/uninstall.mjs +++ b/scripts/uninstall.mjs @@ -1,38 +1,19 @@ -import { mkdir, rm } from "node:fs/promises"; -import { execFileSync } from "node:child_process"; +import { readFile, rm } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { createBridgeInstaller } from "../integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js"; -const home = homedir(); -const uid = process.getuid(); -const appRoot = join(home, "Library", "Application Support", "OpenCodexMicro"); -const bridgeApp = join(home, "Applications", "Codex Bridge.app"); -const agentsRoot = join(home, "Library", "LaunchAgents"); -const plugin = join( - home, - "Library", - "Application Support", - "Ulanzi", - "UlanziDeck", - "Plugins", - "com.ulanzi.codexmicro.ulanziPlugin" -); -await mkdir(agentsRoot, { recursive: true }); -const agents = [ - join(agentsRoot, "io.opencodexmicro.bridge.plist") -]; +const pluginRoot = resolve("integration/com.ulanzi.codexmicro.ulanziPlugin"); +const packageMetadata = JSON.parse(await readFile(resolve("package.json"), "utf8")); +const installer = createBridgeInstaller({ + pluginRoot, + bridgeUrl: process.env.CODEX_BRIDGE_URL || "http://127.0.0.1:17373", + version: String(packageMetadata.version) +}); +await installer.uninstall(); -for (const agent of agents) { - try { - execFileSync("/bin/launchctl", ["bootout", `gui/${uid}`, agent], { - stdio: "ignore" - }); - } catch { - // Already stopped or never installed. - } - await rm(agent, { force: true }); -} -await rm(appRoot, { recursive: true, force: true }); -await rm(bridgeApp, { recursive: true, force: true }); -await rm(plugin, { recursive: true, force: true }); +const pluginsRoot = process.platform === "win32" + ? join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Ulanzi", "UlanziDeck", "Plugins") + : join(homedir(), "Library", "Application Support", "Ulanzi", "UlanziDeck", "Plugins"); +await rm(join(pluginsRoot, "com.ulanzi.codexmicro.ulanziPlugin"), { recursive: true, force: true }); console.log("OpenCodexMicro removed."); diff --git a/skills/install-ulanzi-studio-plugin/SKILL.md b/skills/install-ulanzi-studio-plugin/SKILL.md index 9e9cb49..6a2859c 100644 --- a/skills/install-ulanzi-studio-plugin/SKILL.md +++ b/skills/install-ulanzi-studio-plugin/SKILL.md @@ -1,6 +1,6 @@ --- name: install-ulanzi-studio-plugin -description: Install, update, verify, or diagnose the repository's prebuilt Codex Micro .ulanziPlugin directory in Ulanzi Studio on macOS. Use for local Ulanzi Studio plugin installation, not plugin development or Codex Bridge.app setup. +description: Install, update, verify, or diagnose the prebuilt Codex Micro .ulanziPlugin directory in Ulanzi Studio on Windows or macOS. Use for local plugin installation, not plugin development or Codex Bridge setup. --- # Install the Ulanzi Studio Plugin @@ -13,7 +13,7 @@ Resolve the project root as two directories above this file. Require 1. Inspect `git status --short`, the plugin `manifest.json`, and its committed `CodePath`. Preserve unrelated worktree changes. -2. Verify macOS, Node.js 20+, and `/Applications/Ulanzi Studio.app`. +2. Verify Windows 10+ or macOS 13+, Node.js 20+, and Ulanzi Studio 3.0.1+. 3. If Ulanzi Studio is running, ask the user to quit it before replacing the loaded plugin. Do not terminate the app without permission. 4. From the project root, run: @@ -25,17 +25,10 @@ Resolve the project root as two directories above this file. Require The installer validates the prebuilt manifest entry point, then atomically replaces the installed directory. It must not rebuild the plugin during installation. -5. Verify: - - ```bash - plugin="$HOME/Library/Application Support/Ulanzi/UlanziDeck/Plugins/com.ulanzi.codexmicro.ulanziPlugin" - test -f "$plugin/manifest.json" - test -f "$plugin/dist/app.js" - test -f "$plugin/dist/package.json" - for locale in en zh_CN zh_HK ja_JP de_DE ko_KR pt_PT es_ES; do - test -f "$plugin/$locale.json" - done - ``` +5. Verify the platform-specific destination: `%APPDATA%\Ulanzi\UlanziDeck\Plugins` + on Windows or `~/Library/Application Support/Ulanzi/UlanziDeck/Plugins` on + macOS. Confirm `manifest.json`, `dist/app.js`, `dist/package.json`, all eight + locale files, icons, banners, Property Inspector, and installer resources. 6. Ask the user to reopen Ulanzi Studio and confirm that the **Codex Micro** category and actions appear. If actions show offline, verify the Bridge with diff --git a/skills/setup-codex-bridge/SKILL.md b/skills/setup-codex-bridge/SKILL.md index e43ea6d..335cb0a 100644 --- a/skills/setup-codex-bridge/SKILL.md +++ b/skills/setup-codex-bridge/SKILL.md @@ -1,6 +1,6 @@ --- name: setup-codex-bridge -description: Install, update, verify, diagnose, or remove OpenCodexMicro's Codex Bridge.app and loopback sidecar on macOS. Use when the request concerns Bridge setup or its local LaunchAgent, not Ulanzi Studio plugin installation. +description: Install, update, verify, diagnose, or remove OpenCodexMicro's local Codex Bridge on Windows or macOS. Use for Bridge setup and lifecycle, not Ulanzi Studio plugin installation. --- # Setup Codex Bridge @@ -13,8 +13,9 @@ OpenCodexMicro checkout. 1. Inspect `git status --short`, `package.json`, and the current Bridge state. Preserve unrelated worktree changes. -2. Verify macOS, Node.js 20+, and - `/Applications/ChatGPT.app/Contents/MacOS/ChatGPT`. +2. Verify Windows 10+ or macOS 13+, Node.js 20+, and an installed Codex Desktop. + On Windows, discover `OpenAI.Codex` or `OpenAI.CodexBeta` through Appx package + metadata; never pin a versioned `WindowsApps` path. 3. Install or update the Bridge: ```bash @@ -24,24 +25,24 @@ OpenCodexMicro checkout. Use `npm run setup -- --no-start` only when the user explicitly wants the sidecar installed but stopped. -4. Verify the installed artifacts and service: +4. Verify the installed artifacts and service. Windows stores Bridge data under + `%LOCALAPPDATA%\OpenCodexMicro`; macOS uses + `~/Library/Application Support/OpenCodexMicro` and a user LaunchAgent. ```bash - test -x "$HOME/Applications/Codex Bridge.app/Contents/MacOS/Codex Bridge" - launchctl print "gui/$(id -u)/io.opencodexmicro.bridge" - curl --fail http://127.0.0.1:17373/health + curl --fail http://127.0.0.1:17373/health ``` -5. Do not launch `Codex Bridge.app` merely to verify installation. Launching it - quits any running Codex instance; do so only when the user asks and has had +5. Do not launch Codex merely to verify installation. Applying CDP arguments may + restart the selected Codex channel; do so only when the user asks and has had a chance to save current work. 6. When launched, verify `/state` and report whether `connected` is true. If it is false, inspect `bridge-error.log` and confirm Codex was opened through the - wrapper app. + Bridge launcher. ## Boundaries - Keep CDP at `127.0.0.1:9222` and the sidecar at `127.0.0.1:17373`. -- Do not modify Codex shortcuts or Ulanzi Studio plugins in this skill. +- Do not create version-pinned Codex shortcuts or modify Ulanzi Studio plugins in this skill. - Run `npm run uninstall` only when the user explicitly requests removal; it also removes the installed Codex Micro plugin. diff --git a/skills/setup-codex-bridge/agents/openai.yaml b/skills/setup-codex-bridge/agents/openai.yaml index f9ded0a..1eae316 100644 --- a/skills/setup-codex-bridge/agents/openai.yaml +++ b/skills/setup-codex-bridge/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Setup Codex Bridge" - short_description: "Install and verify Codex Bridge.app on macOS" - default_prompt: "Use $setup-codex-bridge to install and verify Codex Bridge.app." + short_description: "Install Codex Bridge on Windows or macOS" + default_prompt: "Use $setup-codex-bridge to install and verify the local Codex Bridge." diff --git a/src/bridge/auth.mjs b/src/bridge/auth.mjs new file mode 100644 index 0000000..51ed5f9 --- /dev/null +++ b/src/bridge/auth.mjs @@ -0,0 +1,9 @@ +import { timingSafeEqual } from "node:crypto"; + +export function bridgeRequestAuthorized(token, authorizationHeader) { + if (!token) return false; + const candidate = String(authorizationHeader || "").replace(/^Bearer\s+/i, ""); + const actual = Buffer.from(token); + const supplied = Buffer.from(candidate); + return actual.length === supplied.length && timingSafeEqual(actual, supplied); +} diff --git a/src/bridge/codex-cdp.mjs b/src/bridge/codex-cdp.mjs index b6d6cc1..32a0587 100644 --- a/src/bridge/codex-cdp.mjs +++ b/src/bridge/codex-cdp.mjs @@ -1,9 +1,7 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { discoverDebugPort, fetchJson } from "./platform.mjs"; import WebSocket from "ws"; import { localThreadKey } from "./thread-key.mjs"; -const execFileAsync = promisify(execFile); const USAGE_REFRESH_MS = Math.max( 15000, Number(process.env.CODEX_KEYBOARD_USAGE_REFRESH_SECONDS || 600) * 1000 @@ -347,26 +345,6 @@ function selectMainTarget(targets) { }) ?? pages.find((target) => !/avatar-overlay|composition-surface/i.test(target.url || "")); } -async function fetchJson(url, timeout = 1200) { - const response = await fetch(url, { signal: AbortSignal.timeout(timeout) }); - if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); - return response.json(); -} - -async function discoverDebugPort() { - const { stdout } = await execFileAsync("/bin/ps", ["-axo", "command="], { timeout: 4000 }); - for (const line of stdout.split("\n")) { - if (!line.includes("--remote-debugging-address=127.0.0.1")) continue; - const port = Number(line.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); - if (!Number.isInteger(port)) continue; - try { - await fetchJson(`http://127.0.0.1:${port}/json/version`, 500); - return port; - } catch {} - } - throw new Error("Codex is not running with the local debug bridge"); -} - export class CodexCdpClient { socket = null; nextId = 0; diff --git a/src/bridge/platform.mjs b/src/bridge/platform.mjs new file mode 100644 index 0000000..958f31a --- /dev/null +++ b/src/bridge/platform.mjs @@ -0,0 +1,197 @@ +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export const CDP_HOST = "127.0.0.1"; +export const DEFAULT_CDP_PORT = 9222; +export const CDP_ARGUMENTS = Object.freeze([ + `--remote-debugging-address=${CDP_HOST}`, + `--remote-debugging-port=${DEFAULT_CDP_PORT}`, + `--remote-allow-origins=http://${CDP_HOST}:${DEFAULT_CDP_PORT}` +]); + +const WINDOWS_PROCESS_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' } | + ForEach-Object { $_.CommandLine } +`; + +const WINDOWS_PACKAGE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$rows = foreach ($name in @('OpenAI.Codex', 'OpenAI.CodexBeta')) { + $package = Get-AppxPackage -Name $name | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $package) { continue } + $app = Join-Path $package.InstallLocation 'app' + $names = if ($name -eq 'OpenAI.CodexBeta') { + @('ChatGPT (Beta).exe', 'Codex (Beta).exe', 'ChatGPT.exe') + } else { + @('ChatGPT.exe', 'Codex.exe') + } + foreach ($file in $names) { + $candidate = Join-Path $app $file + if (Test-Path -LiteralPath $candidate) { + [pscustomobject]@{ + channel = if ($name -eq 'OpenAI.CodexBeta') { 'beta' } else { 'stable' } + packageName = $name + packageFullName = $package.PackageFullName + executable = $candidate + } + break + } + } +} +@($rows) | ConvertTo-Json -Compress +`; + +const WINDOWS_FOCUS_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class CodexWindow { + [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); +} +'@ +$debugPids = @(Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' -and $_.CommandLine -notlike '*--type=*' } | + Select-Object -ExpandProperty ProcessId) +$candidate = Get-Process | + Where-Object { $_.MainWindowHandle -ne 0 -and ($_.ProcessName -like 'ChatGPT*' -or $_.ProcessName -like 'Codex*') } | + Sort-Object @{ Expression = { if ($debugPids -contains $_.Id) { 0 } else { 1 } } }, StartTime | + Select-Object -First 1 +if (-not $candidate) { throw 'A Codex Desktop window was not found.' } +[CodexWindow]::ShowWindowAsync($candidate.MainWindowHandle, 9) | Out-Null +if (-not [CodexWindow]::SetForegroundWindow($candidate.MainWindowHandle)) { + $shell = New-Object -ComObject WScript.Shell + if (-not $shell.AppActivate($candidate.Id)) { + throw 'Windows did not allow Codex Desktop to receive focus.' + } +} +`; + +const WINDOWS_STOP_EXECUTABLE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$target = $env:CODEX_BRIDGE_TARGET_EXECUTABLE +$processes = @(Get-CimInstance Win32_Process | + Where-Object { + $_.ExecutablePath -eq $target -and + $_.CommandLine -notlike '*--type=*' -and + $_.CommandLine -notlike '*crashpad-handler*' + }) +foreach ($process in $processes) { Stop-Process -Id $process.ProcessId -ErrorAction Stop } +foreach ($process in $processes) { Wait-Process -Id $process.ProcessId -Timeout 8 -ErrorAction SilentlyContinue } +`; + +function powershellArgs(command, extra = []) { + return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command, ...extra]; +} + +export function debugPortsFromCommandLines(text) { + const ports = []; + for (const line of String(text || "").split(/\r?\n/)) { + if (!line.includes("--remote-debugging-address=127.0.0.1")) continue; + const port = Number(line.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); + if (Number.isInteger(port) && port > 0 && port <= 65535) ports.push(port); + } + return [...new Set(ports)]; +} + +export async function fetchJson(url, timeout = 1200, fetchImpl = fetch) { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); + return response.json(); +} + +async function processCommandLines(platform, execute) { + if (platform === "win32") { + return (await execute("powershell.exe", powershellArgs(WINDOWS_PROCESS_COMMAND), { timeout: 4000 })).stdout; + } + if (platform === "darwin") { + return (await execute("/bin/ps", ["-axo", "command="], { timeout: 4000 })).stdout; + } + return ""; +} + +export async function discoverDebugPort({ + platform = process.platform, + execute = execFileAsync, + fetchImpl = fetch, + preferredPort = DEFAULT_CDP_PORT +} = {}) { + try { + await fetchJson(`http://${CDP_HOST}:${preferredPort}/json/version`, 500, fetchImpl); + return preferredPort; + } catch {} + const candidates = []; + try { + candidates.push(...debugPortsFromCommandLines(await processCommandLines(platform, execute))); + } catch { + // Direct loopback probing remains available when process inspection is denied. + } + for (const port of [...new Set(candidates)].filter((port) => port !== preferredPort)) { + try { + await fetchJson(`http://${CDP_HOST}:${port}/json/version`, 500, fetchImpl); + return port; + } catch {} + } + throw new Error("Codex is not running with the local debug bridge"); +} + +export async function discoverWindowsCodexExecutables({ execute = execFileAsync } = {}) { + const { stdout = "" } = await execute( + "powershell.exe", + powershellArgs(WINDOWS_PACKAGE_COMMAND), + { timeout: 8000 } + ); + if (!String(stdout).trim()) return []; + const parsed = JSON.parse(String(stdout)); + const rows = Array.isArray(parsed) ? parsed : [parsed]; + return rows.filter((row) => + ["stable", "beta"].includes(row?.channel) && + typeof row?.executable === "string" && row.executable.toLowerCase().endsWith(".exe") + ); +} + +export async function focusCodex({ platform = process.platform, execute = execFileAsync } = {}) { + if (platform === "win32") { + await execute("powershell.exe", powershellArgs(WINDOWS_FOCUS_COMMAND), { timeout: 5000 }); + return; + } + if (platform === "darwin") { + await execute("/usr/bin/open", ["-b", "com.openai.codex"], { timeout: 3000 }); + return; + } + throw new Error(`Codex Desktop focus is not supported on ${platform}`); +} + +export async function launchWindowsCodex({ + channel = "stable", + execute = execFileAsync, + spawnProcess = spawn +} = {}) { + const installations = await discoverWindowsCodexExecutables({ execute }); + const selected = installations.find((item) => item.channel === channel) + ?? installations.find((item) => item.channel === "stable") + ?? installations[0]; + if (!selected) { + throw new Error("Codex Desktop Stable or Beta was not found in the current Windows account."); + } + await execute( + "powershell.exe", + powershellArgs(WINDOWS_STOP_EXECUTABLE_COMMAND), + { + timeout: 10000, + env: { ...process.env, CODEX_BRIDGE_TARGET_EXECUTABLE: selected.executable } + } + ); + const child = spawnProcess(selected.executable, [...CDP_ARGUMENTS], { + detached: true, + stdio: "ignore", + windowsHide: false + }); + child.unref?.(); + return selected; +} diff --git a/src/bridge/server.mjs b/src/bridge/server.mjs index a0b7056..ea8b005 100644 --- a/src/bridge/server.mjs +++ b/src/bridge/server.mjs @@ -1,10 +1,12 @@ import { createServer } from "node:http"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { CodexCdpClient } from "./codex-cdp.mjs"; +import { bridgeRequestAuthorized } from "./auth.mjs"; +import { focusCodex } from "./platform.mjs"; import { decodeThreadPathSegment } from "./thread-key.mjs"; -const execFileAsync = promisify(execFile); const HOST = "127.0.0.1"; const PORT = Number(process.env.CODEX_KEYBOARD_PORT || 17373); const configuredRefreshMs = Number(process.env.CODEX_KEYBOARD_REFRESH_MS || 500); @@ -12,6 +14,13 @@ const REFRESH_MS = Number.isFinite(configuredRefreshMs) ? Math.max(250, configuredRefreshMs) : 500; const client = new CodexCdpClient(); +const dataRoot = process.env.CODEX_BRIDGE_DATA_ROOT || (process.platform === "win32" + ? join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "OpenCodexMicro") + : join(homedir(), "Library", "Application Support", "OpenCodexMicro")); +const bridgeToken = process.env.CODEX_BRIDGE_TOKEN || (() => { + try { return readFileSync(join(dataRoot, "bridge-token"), "utf8").trim(); } + catch { return ""; } +})(); let cached = { connected: false, slots: Array.from({ length: 6 }, (_, id) => ({ @@ -23,10 +32,8 @@ let cached = { let refreshPromise = null; let nextReconnectAt = 0; -async function focusCodex() { - await execFileAsync("/usr/bin/open", ["-b", "com.openai.codex"], { - timeout: 3000 - }); +function authorized(request) { + return bridgeRequestAuthorized(bridgeToken, request.headers.authorization); } async function refresh(force = false) { @@ -60,6 +67,9 @@ function json(response, status, body) { const server = createServer(async (request, response) => { const url = new URL(request.url || "/", `http://${HOST}:${PORT}`); + if (request.method === "POST" && !authorized(request)) { + return json(response, 401, { ok: false, error: "Bridge authorization required" }); + } if (request.method === "GET" && url.pathname === "/health") { await refresh(true); return json(response, 200, { ok: true, codexConnected: cached.connected, updatedAt: cached.updatedAt }); @@ -69,7 +79,7 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && url.pathname === "/focus") { try { - await focusCodex(); + await focusCodex(); return json(response, 200, { ok: true }); } catch (error) { return json(response, 503, { ok: false, error: error.message }); diff --git a/test/bridge-installer_test.mjs b/test/bridge-installer_test.mjs index e663abb..04b82f1 100644 --- a/test/bridge-installer_test.mjs +++ b/test/bridge-installer_test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { + bridgeDataRoot, createBridgeInstaller, selectBridgeNodeRuntime } from "../integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js"; @@ -111,3 +112,89 @@ test("bundled installer falls back when the system Node is older than version 20 await rm(home, { recursive: true, force: true }); } }); + +test("Windows installer uses LocalAppData, a capability token, and user-level processes", async () => { + const home = await mkdtemp(join(tmpdir(), "codex-micro-windows-installer-")); + const localAppData = join(home, "LocalAppData"); + const bin = join(home, "bin"); + const systemNode = join(bin, "node.exe"); + const codexExecutable = join(home, "WindowsApps", "OpenAI.Codex_1.2.3.4_x64", "app", "ChatGPT.exe"); + await mkdir(bin, { recursive: true }); + await writeFile(systemNode, "fake node"); + const commands = []; + const children = []; + const execute = async (command, args, options) => { + commands.push([command, args, options]); + if (args[0] === "--version") return { stdout: "v22.14.0\n", stderr: "" }; + if (command === "powershell.exe" && args.join(" ").includes("Get-AppxPackage")) { + return { + stdout: JSON.stringify({ + channel: "stable", + packageName: "OpenAI.Codex", + packageFullName: "OpenAI.Codex_1.2.3.4_x64__test", + executable: codexExecutable + }), + stderr: "" + }; + } + return { stdout: "", stderr: "" }; + }; + const spawnProcess = (command, args, options) => { + children.push({ command, args, options }); + return { unref() {} }; + }; + const installer = createBridgeInstaller({ + pluginRoot, + bridgeUrl: "http://127.0.0.1:59999", + version: "9.8.7", + home, + localAppData, + platform: "win32", + nodeExecutable: process.execPath, + environmentPath: bin, + execute, + spawnProcess, + fetchImpl: async () => { throw new Error("offline fixture"); } + }); + + try { + assert.equal( + bridgeDataRoot({ platform: "win32", home, localAppData }), + join(localAppData, "OpenCodexMicro") + ); + const installed = await installer.install(); + assert.equal(installed.supported, true); + assert.equal(installed.installed, true); + const dataRoot = join(localAppData, "OpenCodexMicro"); + await access(join(dataRoot, "bridge.mjs")); + const token = (await readFile(join(dataRoot, "bridge-token"), "utf8")).trim(); + assert.match(token, /^[A-Za-z0-9_-]{40,}$/); + assert.match((await installer.authorizationHeaders()).Authorization, /^Bearer /); + assert.equal(children[0].command, await realpath(systemNode)); + assert.equal(children[0].options.windowsHide, true); + assert.equal(children[0].options.env.CODEX_BRIDGE_TOKEN, token); + + await installer.launch(); + assert.equal(children[1].command, codexExecutable); + assert.deepEqual(children[1].args, [ + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=9222", + "--remote-allow-origins=http://127.0.0.1:9222" + ]); + assert.equal(children[1].options.windowsHide, false); + assert.ok(commands.some(([command, args]) => + command === "powershell.exe" && args.join(" ").includes("Stop-Process") + )); + assert.ok(commands.some(([command, args, options]) => + command === "powershell.exe" && + args.join(" ").includes("Wait-Process") && + options?.env?.CODEX_BRIDGE_TARGET_EXECUTABLE === codexExecutable + )); + + const uninstalled = await installer.uninstall(); + assert.equal(uninstalled.installed, false); + await assert.rejects(access(dataRoot)); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/test/bridge_test.mjs b/test/bridge_test.mjs index 78c8e72..9f0eb3f 100644 --- a/test/bridge_test.mjs +++ b/test/bridge_test.mjs @@ -12,9 +12,55 @@ import { composerSteerExpression, rendererActionExpression } from "../src/bridge/codex-cdp.mjs"; +import { + debugPortsFromCommandLines, + discoverDebugPort, + discoverWindowsCodexExecutables +} from "../src/bridge/platform.mjs"; +import { bridgeRequestAuthorized } from "../src/bridge/auth.mjs"; const UUID = "f6805b8a-332a-43a0-a118-52d3e59542f6"; +test("Bridge write authorization fails closed and accepts only the installed token", () => { + const token = "fixture-local-capability-token"; + assert.equal(bridgeRequestAuthorized("", `Bearer ${token}`), false); + assert.equal(bridgeRequestAuthorized(token, ""), false); + assert.equal(bridgeRequestAuthorized(token, "Bearer wrong-token"), false); + assert.equal(bridgeRequestAuthorized(token, `Bearer ${token}`), true); +}); + +test("CDP discovery probes the fixed loopback port before platform process inspection", async () => { + const requests = []; + let processInspections = 0; + const port = await discoverDebugPort({ + platform: "win32", + execute: async () => { processInspections += 1; throw new Error("process inspection denied"); }, + fetchImpl: async (url) => { + requests.push(url); + return { ok: true, json: async () => ({ Browser: "Chrome" }) }; + } + }); + assert.equal(port, 9222); + assert.deepEqual(requests, ["http://127.0.0.1:9222/json/version"]); + assert.equal(processInspections, 0); +}); + +test("Windows CDP and Appx discovery accept Stable and Beta process shapes", async () => { + assert.deepEqual(debugPortsFromCommandLines([ + '"ChatGPT.exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222', + '"ChatGPT (Beta).exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port 9333' + ].join("\n")), [9222, 9333]); + const installs = await discoverWindowsCodexExecutables({ + execute: async () => ({ + stdout: JSON.stringify([ + { channel: "stable", packageName: "OpenAI.Codex", executable: "C:\\WindowsApps\\Stable\\app\\ChatGPT.exe" }, + { channel: "beta", packageName: "OpenAI.CodexBeta", executable: "C:\\WindowsApps\\Beta\\app\\ChatGPT (Beta).exe" } + ]) + }) + }); + assert.deepEqual(installs.map((item) => item.channel), ["stable", "beta"]); +}); + test("accepts formal and explicit temporary Codex thread ids", () => { assert.equal(validateThreadId(UUID), UUID); assert.equal( From c617849b60af461c17e1f4d84dd712d46ddbcc04 Mon Sep 17 00:00:00 2001 From: yyf <2358929807@qq.com> Date: Fri, 21 Aug 2026 12:57:46 +0800 Subject: [PATCH 2/8] fix: hide Windows PowerShell helper windows --- .../plugin/bridge-installer.js | 1 + src/bridge/platform.mjs | 22 +++++++--- test/bridge-installer_test.mjs | 3 ++ test/bridge_test.mjs | 43 ++++++++++++++++--- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js index 55ecd07..d8d5117 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js @@ -387,6 +387,7 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi try { await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", stopScript], { timeout: 5000, + windowsHide: true, env: { ...process.env, CODEX_BRIDGE_TARGET_RUNTIME: bridgeRuntime } }); } catch {} diff --git a/src/bridge/platform.mjs b/src/bridge/platform.mjs index 958f31a..ce14dc6 100644 --- a/src/bridge/platform.mjs +++ b/src/bridge/platform.mjs @@ -89,6 +89,10 @@ function powershellArgs(command, extra = []) { return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command, ...extra]; } +function powershellOptions(options = {}) { + return { ...options, windowsHide: true }; +} + export function debugPortsFromCommandLines(text) { const ports = []; for (const line of String(text || "").split(/\r?\n/)) { @@ -107,7 +111,11 @@ export async function fetchJson(url, timeout = 1200, fetchImpl = fetch) { async function processCommandLines(platform, execute) { if (platform === "win32") { - return (await execute("powershell.exe", powershellArgs(WINDOWS_PROCESS_COMMAND), { timeout: 4000 })).stdout; + return (await execute( + "powershell.exe", + powershellArgs(WINDOWS_PROCESS_COMMAND), + powershellOptions({ timeout: 4000 }) + )).stdout; } if (platform === "darwin") { return (await execute("/bin/ps", ["-axo", "command="], { timeout: 4000 })).stdout; @@ -144,7 +152,7 @@ export async function discoverWindowsCodexExecutables({ execute = execFileAsync const { stdout = "" } = await execute( "powershell.exe", powershellArgs(WINDOWS_PACKAGE_COMMAND), - { timeout: 8000 } + powershellOptions({ timeout: 8000 }) ); if (!String(stdout).trim()) return []; const parsed = JSON.parse(String(stdout)); @@ -157,7 +165,11 @@ export async function discoverWindowsCodexExecutables({ execute = execFileAsync export async function focusCodex({ platform = process.platform, execute = execFileAsync } = {}) { if (platform === "win32") { - await execute("powershell.exe", powershellArgs(WINDOWS_FOCUS_COMMAND), { timeout: 5000 }); + await execute( + "powershell.exe", + powershellArgs(WINDOWS_FOCUS_COMMAND), + powershellOptions({ timeout: 5000 }) + ); return; } if (platform === "darwin") { @@ -182,10 +194,10 @@ export async function launchWindowsCodex({ await execute( "powershell.exe", powershellArgs(WINDOWS_STOP_EXECUTABLE_COMMAND), - { + powershellOptions({ timeout: 10000, env: { ...process.env, CODEX_BRIDGE_TARGET_EXECUTABLE: selected.executable } - } + }) ); const child = spawnProcess(selected.executable, [...CDP_ARGUMENTS], { detached: true, diff --git a/test/bridge-installer_test.mjs b/test/bridge-installer_test.mjs index 04b82f1..43e4de6 100644 --- a/test/bridge-installer_test.mjs +++ b/test/bridge-installer_test.mjs @@ -194,6 +194,9 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr const uninstalled = await installer.uninstall(); assert.equal(uninstalled.installed, false); await assert.rejects(access(dataRoot)); + const powershellCommands = commands.filter(([command]) => command === "powershell.exe"); + assert.ok(powershellCommands.length >= 4); + assert.ok(powershellCommands.every(([, , options]) => options?.windowsHide === true)); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/test/bridge_test.mjs b/test/bridge_test.mjs index 9f0eb3f..b44a3c7 100644 --- a/test/bridge_test.mjs +++ b/test/bridge_test.mjs @@ -15,7 +15,8 @@ import { import { debugPortsFromCommandLines, discoverDebugPort, - discoverWindowsCodexExecutables + discoverWindowsCodexExecutables, + focusCodex } from "../src/bridge/platform.mjs"; import { bridgeRequestAuthorized } from "../src/bridge/auth.mjs"; @@ -50,15 +51,43 @@ test("Windows CDP and Appx discovery accept Stable and Beta process shapes", asy '"ChatGPT.exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222', '"ChatGPT (Beta).exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port 9333' ].join("\n")), [9222, 9333]); + let appxOptions; const installs = await discoverWindowsCodexExecutables({ - execute: async () => ({ - stdout: JSON.stringify([ - { channel: "stable", packageName: "OpenAI.Codex", executable: "C:\\WindowsApps\\Stable\\app\\ChatGPT.exe" }, - { channel: "beta", packageName: "OpenAI.CodexBeta", executable: "C:\\WindowsApps\\Beta\\app\\ChatGPT (Beta).exe" } - ]) - }) + execute: async (_command, _args, options) => { + appxOptions = options; + return { + stdout: JSON.stringify([ + { channel: "stable", packageName: "OpenAI.Codex", executable: "C:\\WindowsApps\\Stable\\app\\ChatGPT.exe" }, + { channel: "beta", packageName: "OpenAI.CodexBeta", executable: "C:\\WindowsApps\\Beta\\app\\ChatGPT (Beta).exe" } + ]) + }; + } }); assert.deepEqual(installs.map((item) => item.channel), ["stable", "beta"]); + assert.equal(appxOptions.windowsHide, true); +}); + +test("Windows background PowerShell calls hide their console windows", async () => { + const calls = []; + const execute = async (command, args, options) => { + calls.push({ command, args, options }); + return { + stdout: '\"ChatGPT.exe\" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9333\n' + }; + }; + await discoverDebugPort({ + platform: "win32", + execute, + fetchImpl: async (url) => { + if (url.includes(":9333/")) return { ok: true, json: async () => ({ Browser: "Chrome" }) }; + throw new Error("offline fixture"); + } + }); + await focusCodex({ platform: "win32", execute }); + assert.equal(calls.length, 2); + assert.ok(calls.every(({ command, options }) => + command === "powershell.exe" && options?.windowsHide === true + )); }); test("accepts formal and explicit temporary Codex thread ids", () => { From 57c84c1acea5656832b870d6202ac5d60b229ea5 Mon Sep 17 00:00:00 2001 From: yyf <2358929807@qq.com> Date: Fri, 21 Aug 2026 12:57:46 +0800 Subject: [PATCH 3/8] perf: reduce Codex control action latency --- .../plugin/bridge-installer.js | 32 +++++++- src/bridge/codex-cdp.mjs | 77 +++++++++++++++---- src/bridge/platform.mjs | 70 ++++++++++++----- src/bridge/server.mjs | 6 +- test/bridge-installer_test.mjs | 12 ++- test/bridge_test.mjs | 68 +++++++++++++++- 6 files changed, 226 insertions(+), 39 deletions(-) diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js index d8d5117..0770e9a 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js @@ -163,13 +163,35 @@ export function createBridgeInstaller({ const agentsRoot = join(home, "Library", "LaunchAgents"); const bridgeAgent = join(agentsRoot, "io.opencodexmicro.bridge.plist"); let lastWindowsServiceStart = 0; + let authorizationCache = null; + let authorizationPromise = null; + let authorizationGeneration = 0; + + function resetAuthorizationCache() { + authorizationGeneration += 1; + authorizationCache = null; + authorizationPromise = null; + } async function authorizationHeaders() { + if (authorizationCache) return authorizationCache; + if (authorizationPromise) return authorizationPromise; + const generation = authorizationGeneration; + const operation = (async () => { + try { + const token = (await readFile(tokenPath, "utf8")).trim(); + const headers = token ? Object.freeze({ Authorization: `Bearer ${token}` }) : {}; + if (token && generation === authorizationGeneration) authorizationCache = headers; + return headers; + } catch { + return {}; + } + })(); + authorizationPromise = operation; try { - const token = (await readFile(tokenPath, "utf8")).trim(); - return token ? { Authorization: `Bearer ${token}` } : {}; - } catch { - return {}; + return await operation; + } finally { + if (authorizationPromise === operation) authorizationPromise = null; } } @@ -315,6 +337,7 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi if (!await exists(tokenPath)) { await writeFile(tokenPath, `${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 }); } + resetAuthorizationCache(); await writeFile(installMetadata, `${JSON.stringify({ version, platform, @@ -393,6 +416,7 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi } catch {} } await rm(appRoot, { recursive: true, force: true }); + resetAuthorizationCache(); return status(); } diff --git a/src/bridge/codex-cdp.mjs b/src/bridge/codex-cdp.mjs index 32a0587..7467f48 100644 --- a/src/bridge/codex-cdp.mjs +++ b/src/bridge/codex-cdp.mjs @@ -347,26 +347,73 @@ function selectMainTarget(targets) { export class CodexCdpClient { socket = null; + connectPromise = null; + connectionGeneration = 0; nextId = 0; pending = new Map(); lastSnapshot = null; + constructor({ + discoverPort = discoverDebugPort, + fetchTargets = fetchJson, + createSocket = url => new WebSocket(url) + } = {}) { + this.discoverPort = discoverPort; + this.fetchTargets = fetchTargets; + this.createSocket = createSocket; + } + async connect() { if (this.socket?.readyState === WebSocket.OPEN) return; - const port = await discoverDebugPort(); - const target = selectMainTarget(await fetchJson(`http://127.0.0.1:${port}/json/list`)); + if (this.connectPromise) return this.connectPromise; + const generation = ++this.connectionGeneration; + const operation = this.openConnection(generation); + this.connectPromise = operation; + try { + return await operation; + } finally { + if (this.connectPromise === operation) this.connectPromise = null; + } + } + + async openConnection(generation) { + const port = await this.discoverPort(); + const target = selectMainTarget(await this.fetchTargets(`http://127.0.0.1:${port}/json/list`)); if (!target?.webSocketDebuggerUrl) throw new Error("Codex main renderer was not found"); - const socket = new WebSocket(target.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("Timed out connecting to Codex")), 3000); - socket.once("open", () => { clearTimeout(timer); resolve(); }); - socket.once("error", reject); - }); - socket.on("message", (raw) => this.handleMessage(String(raw))); - socket.on("close", () => this.disconnect()); - socket.on("error", () => this.disconnect()); + if (generation !== this.connectionGeneration) throw new Error("Codex bridge connection was cancelled"); + const socket = this.createSocket(target.webSocketDebuggerUrl); this.socket = socket; - await this.evaluate(ENABLE_EXPRESSION); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Timed out connecting to Codex")); + }, 3000); + const cleanup = () => { + clearTimeout(timer); + socket.off("open", onOpen); + socket.off("error", onError); + }; + const onOpen = () => { cleanup(); resolve(); }; + const onError = error => { cleanup(); reject(error); }; + socket.once("open", onOpen); + socket.once("error", onError); + }); + if (generation !== this.connectionGeneration || this.socket !== socket) { + throw new Error("Codex bridge connection was cancelled"); + } + socket.on("message", (raw) => this.handleMessage(String(raw))); + socket.on("close", () => { + if (this.socket === socket) this.disconnect(); + }); + socket.on("error", () => { + if (this.socket === socket) this.disconnect(); + }); + await this.evaluate(ENABLE_EXPRESSION); + } catch (error) { + if (this.socket === socket) this.disconnect(); + throw error; + } } async snapshot() { @@ -555,9 +602,13 @@ export class CodexCdpClient { } disconnect() { + this.connectionGeneration += 1; + this.connectPromise = null; const socket = this.socket; this.socket = null; - if (socket?.readyState === WebSocket.OPEN) socket.close(); + if (socket && socket.readyState !== WebSocket.CLOSED) { + try { socket.close(); } catch { socket.terminate?.(); } + } for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(new Error("Codex bridge disconnected")); diff --git a/src/bridge/platform.mjs b/src/bridge/platform.mjs index ce14dc6..06a2b43 100644 --- a/src/bridge/platform.mjs +++ b/src/bridge/platform.mjs @@ -2,6 +2,8 @@ import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +let windowsFocusPid = null; +let windowsFocusPromise = null; export const CDP_HOST = "127.0.0.1"; export const DEFAULT_CDP_PORT = 9222; @@ -47,7 +49,25 @@ $rows = foreach ($name in @('OpenAI.Codex', 'OpenAI.CodexBeta')) { const WINDOWS_FOCUS_COMMAND = String.raw` $ErrorActionPreference = 'Stop' -Add-Type @' +$preferredPid = 0 +$hasPreferredPid = [int]::TryParse($env:CODEX_BRIDGE_FOCUS_PID, [ref]$preferredPid) +$candidate = if ($hasPreferredPid -and $preferredPid -gt 0) { + Get-Process -Id $preferredPid -ErrorAction SilentlyContinue | + Where-Object { $_.MainWindowHandle -ne 0 } +} else { $null } +if (-not $candidate) { + $debugPids = @(Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' -and $_.CommandLine -notlike '*--type=*' } | + Select-Object -ExpandProperty ProcessId) + $candidate = Get-Process | + Where-Object { $_.MainWindowHandle -ne 0 -and ($_.ProcessName -like 'ChatGPT*' -or $_.ProcessName -like 'Codex*') } | + Sort-Object @{ Expression = { if ($debugPids -contains $_.Id) { 0 } else { 1 } } }, StartTime | + Select-Object -First 1 +} +if (-not $candidate) { throw 'A Codex Desktop window was not found.' } +$shell = New-Object -ComObject WScript.Shell +if (-not $shell.AppActivate($candidate.Id)) { + Add-Type @' using System; using System.Runtime.InteropServices; public static class CodexWindow { @@ -55,21 +75,12 @@ public static class CodexWindow { [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); } '@ -$debugPids = @(Get-CimInstance Win32_Process | - Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' -and $_.CommandLine -notlike '*--type=*' } | - Select-Object -ExpandProperty ProcessId) -$candidate = Get-Process | - Where-Object { $_.MainWindowHandle -ne 0 -and ($_.ProcessName -like 'ChatGPT*' -or $_.ProcessName -like 'Codex*') } | - Sort-Object @{ Expression = { if ($debugPids -contains $_.Id) { 0 } else { 1 } } }, StartTime | - Select-Object -First 1 -if (-not $candidate) { throw 'A Codex Desktop window was not found.' } -[CodexWindow]::ShowWindowAsync($candidate.MainWindowHandle, 9) | Out-Null -if (-not [CodexWindow]::SetForegroundWindow($candidate.MainWindowHandle)) { - $shell = New-Object -ComObject WScript.Shell - if (-not $shell.AppActivate($candidate.Id)) { + [CodexWindow]::ShowWindowAsync($candidate.MainWindowHandle, 9) | Out-Null + if (-not [CodexWindow]::SetForegroundWindow($candidate.MainWindowHandle)) { throw 'Windows did not allow Codex Desktop to receive focus.' } } +$candidate.Id `; const WINDOWS_STOP_EXECUTABLE_COMMAND = String.raw` @@ -165,12 +176,33 @@ export async function discoverWindowsCodexExecutables({ execute = execFileAsync export async function focusCodex({ platform = process.platform, execute = execFileAsync } = {}) { if (platform === "win32") { - await execute( - "powershell.exe", - powershellArgs(WINDOWS_FOCUS_COMMAND), - powershellOptions({ timeout: 5000 }) - ); - return; + if (windowsFocusPromise) return windowsFocusPromise; + const operation = (async () => { + try { + const { stdout = "" } = await execute( + "powershell.exe", + powershellArgs(WINDOWS_FOCUS_COMMAND), + powershellOptions({ + timeout: 5000, + env: { + ...process.env, + ...(windowsFocusPid ? { CODEX_BRIDGE_FOCUS_PID: String(windowsFocusPid) } : {}) + } + }) + ); + const focusedPid = Number(String(stdout).trim().split(/\r?\n/).at(-1)); + windowsFocusPid = Number.isInteger(focusedPid) && focusedPid > 0 ? focusedPid : null; + } catch (error) { + windowsFocusPid = null; + throw error; + } + })(); + windowsFocusPromise = operation; + try { + return await operation; + } finally { + if (windowsFocusPromise === operation) windowsFocusPromise = null; + } } if (platform === "darwin") { await execute("/usr/bin/open", ["-b", "com.openai.codex"], { timeout: 3000 }); diff --git a/src/bridge/server.mjs b/src/bridge/server.mjs index ea8b005..ddaf8e3 100644 --- a/src/bridge/server.mjs +++ b/src/bridge/server.mjs @@ -31,6 +31,7 @@ let cached = { }; let refreshPromise = null; let nextReconnectAt = 0; +let hasRefreshed = false; function authorized(request) { return bridgeRequestAuthorized(bridgeToken, request.headers.authorization); @@ -52,6 +53,7 @@ async function refresh(force = false) { try { await refreshPromise; } finally { + hasRefreshed = true; refreshPromise = null; } } @@ -71,7 +73,9 @@ const server = createServer(async (request, response) => { return json(response, 401, { ok: false, error: "Bridge authorization required" }); } if (request.method === "GET" && url.pathname === "/health") { - await refresh(true); + if (!hasRefreshed || Date.now() - cached.updatedAt >= REFRESH_MS * 2) { + await refresh(true); + } return json(response, 200, { ok: true, codexConnected: cached.connected, updatedAt: cached.updatedAt }); } if (request.method === "GET" && url.pathname === "/state") { diff --git a/test/bridge-installer_test.mjs b/test/bridge-installer_test.mjs index 43e4de6..7bdb011 100644 --- a/test/bridge-installer_test.mjs +++ b/test/bridge-installer_test.mjs @@ -158,6 +158,7 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr }); try { + assert.deepEqual(await installer.authorizationHeaders(), {}); assert.equal( bridgeDataRoot({ platform: "win32", home, localAppData }), join(localAppData, "OpenCodexMicro") @@ -169,7 +170,7 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr await access(join(dataRoot, "bridge.mjs")); const token = (await readFile(join(dataRoot, "bridge-token"), "utf8")).trim(); assert.match(token, /^[A-Za-z0-9_-]{40,}$/); - assert.match((await installer.authorizationHeaders()).Authorization, /^Bearer /); + assert.deepEqual(await installer.authorizationHeaders(), { Authorization: `Bearer ${token}` }); assert.equal(children[0].command, await realpath(systemNode)); assert.equal(children[0].options.windowsHide, true); assert.equal(children[0].options.env.CODEX_BRIDGE_TOKEN, token); @@ -194,9 +195,18 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr const uninstalled = await installer.uninstall(); assert.equal(uninstalled.installed, false); await assert.rejects(access(dataRoot)); + assert.deepEqual(await installer.authorizationHeaders(), {}); const powershellCommands = commands.filter(([command]) => command === "powershell.exe"); assert.ok(powershellCommands.length >= 4); assert.ok(powershellCommands.every(([, , options]) => options?.windowsHide === true)); + + await installer.install(); + const replacementToken = (await readFile(join(dataRoot, "bridge-token"), "utf8")).trim(); + assert.notEqual(replacementToken, token); + assert.deepEqual(await installer.authorizationHeaders(), { + Authorization: `Bearer ${replacementToken}` + }); + await installer.uninstall(); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/test/bridge_test.mjs b/test/bridge_test.mjs index b44a3c7..4b47b7b 100644 --- a/test/bridge_test.mjs +++ b/test/bridge_test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; import test from "node:test"; import vm from "node:vm"; @@ -71,6 +72,9 @@ test("Windows background PowerShell calls hide their console windows", async () const calls = []; const execute = async (command, args, options) => { calls.push({ command, args, options }); + if (args.join(" ").includes("CODEX_BRIDGE_FOCUS_PID")) { + return { stdout: "4242\n" }; + } return { stdout: '\"ChatGPT.exe\" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9333\n' }; @@ -84,10 +88,72 @@ test("Windows background PowerShell calls hide their console windows", async () } }); await focusCodex({ platform: "win32", execute }); - assert.equal(calls.length, 2); + await focusCodex({ platform: "win32", execute }); + assert.equal(calls.length, 3); assert.ok(calls.every(({ command, options }) => command === "powershell.exe" && options?.windowsHide === true )); + const focusCalls = calls.slice(1); + assert.ok(focusCalls.every(({ args, options }) => + args.join(" ").includes("AppActivate") && + args.join(" ").includes("SetForegroundWindow") && + options.timeout === 5000 + )); + assert.equal(focusCalls[1].options.env.CODEX_BRIDGE_FOCUS_PID, "4242"); +}); + +test("concurrent CDP connects share one socket and ignore stale socket events", async () => { + class FakeSocket extends EventEmitter { + readyState = 0; + + constructor() { + super(); + queueMicrotask(() => { + this.readyState = 1; + this.emit("open"); + }); + } + + send(raw) { + const { id } = JSON.parse(raw); + queueMicrotask(() => this.emit("message", JSON.stringify({ + id, + result: { result: { value: true } } + }))); + } + + close() { + this.readyState = 3; + } + } + + let discoveries = 0; + let targetFetches = 0; + const sockets = []; + const client = new CodexCdpClient({ + discoverPort: async () => { discoveries += 1; return 9222; }, + fetchTargets: async () => { + targetFetches += 1; + return [{ type: "page", url: "app://-/index.html", webSocketDebuggerUrl: "ws://fixture" }]; + }, + createSocket: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + } + }); + + await Promise.all([client.connect(), client.connect()]); + assert.equal(discoveries, 1); + assert.equal(targetFetches, 1); + assert.equal(sockets.length, 1); + + const staleSocket = sockets[0]; + client.disconnect(); + await client.connect(); + staleSocket.emit("close"); + assert.equal(client.socket, sockets[1]); + client.disconnect(); }); test("accepts formal and explicit temporary Codex thread ids", () => { From 3071dfde77c3bf9370f5c143a3485efe05c6c5e3 Mon Sep 17 00:00:00 2001 From: yyf <2358929807@qq.com> Date: Fri, 21 Aug 2026 12:57:46 +0800 Subject: [PATCH 4/8] feat: expand Codex task controls --- docs/architecture.md | 8 +- docs/configuration.md | 8 + .../assets/icons/model-luna-max.svg | 5 + .../assets/icons/model-sol-high.svg | 5 + .../assets/icons/model-sol-medium.svg | 5 + .../assets/icons/task-complete.gif | Bin 0 -> 315522 bytes .../de_DE.json | 7 +- .../en.json | 7 +- .../es_ES.json | 7 +- .../ja_JP.json | 7 +- .../ko_KR.json | 7 +- .../manifest.json | 34 ++- .../package-lock.json | 4 +- .../package.json | 2 +- .../plugin/app.js | 14 +- .../pt_PT.json | 7 +- .../test/smoke.mjs | 63 +++- .../zh_CN.json | 7 +- .../zh_HK.json | 7 +- package-lock.json | 4 +- package.json | 2 +- src/bridge/codex-cdp.mjs | 276 +++++++++++++++++- src/bridge/server.mjs | 2 +- test/bridge_test.mjs | 18 +- 24 files changed, 451 insertions(+), 55 deletions(-) create mode 100644 integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-luna-max.svg create mode 100644 integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-high.svg create mode 100644 integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-medium.svg create mode 100644 integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-complete.gif diff --git a/docs/architecture.md b/docs/architecture.md index c5a5425..c8f1e55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,9 +31,11 @@ context map, and usage query clients. Those references are cached for the renderer lifecycle and rediscovered only when they become invalid. Task activation and Fast, Fork, Submit, and Mic use Codex Micro events. Pin, -New, and Steer invoke the matching semantic renderer controls. An uncertain -HTTP failure is never replayed through another mechanism because the first -request may already have executed. +New, Steer, and the three model presets invoke semantic renderer controls. The +model adapter requires the visible intelligence trigger, validates the exact +model option and reasoning-effort ordering, and verifies the final selection. +An uncertain HTTP failure is never replayed through another mechanism because +the first request may already have executed. ## Ulanzi Studio plugin diff --git a/docs/configuration.md b/docs/configuration.md index 886b45c..ee9180f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,12 +23,20 @@ No repository or npm working directory is required for this flow. | Steer | Send the visible composer text as steering input to a running task | | Mic | Press and release the Codex Micro microphone action | | Submit | Submit or queue the composer text | +| Sol High | Switch the visible task to GPT-5.6 Sol with high reasoning effort | +| Luna Max | Switch the visible task to GPT-5.6 Luna with maximum reasoning effort | +| Sol Medium | Switch the visible task to GPT-5.6 Sol with medium reasoning effort | Task actions dynamically use the plugin's idle, working, complete, attention, error, and offline artwork. Usage is rendered at runtime from the allowance returned by the Bridge. The Encoder action mirrors task 1's current title and status artwork. +Model presets operate the visible Codex Desktop intelligence picker. They +validate the target model, the current reasoning-effort ordering, and the final +selection. A running turn is not moved to another model; the preset applies to +the next turn submitted from the visible task. + ## Recommended layout Keep Task 1–5 together in Most Recent order. Place frequently used controls on diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-luna-max.svg b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-luna-max.svg new file mode 100644 index 0000000..8a1bce3 --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-luna-max.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-high.svg b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-high.svg new file mode 100644 index 0000000..faeb66b --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-high.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-medium.svg b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-medium.svg new file mode 100644 index 0000000..eddd09d --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/model-sol-medium.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-complete.gif b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-complete.gif new file mode 100644 index 0000000000000000000000000000000000000000..89c8bdb8095ad3454c6abc93ff917f6b13a75909 GIT binary patch literal 315522 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%0DHaz? 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 ); assert.deepEqual(navigateAction?.Controllers, ["Encoder"]); assert.equal(navigateAction?.Encoder?.layout, "$UA1"); -assert.equal(manifest.Version, "0.5.0"); +assert.equal(manifest.Version, "0.6.0"); assert.equal(manifest.Software?.MinVersion, "3.0.1"); assert.equal(manifest.OS?.find(item => item.Platform === "mac")?.MinimumVersion, "13.0"); assert.equal(manifest.OS?.find(item => item.Platform === "windows")?.MinimumVersion, "10.0"); +const completeGif = await readFile(new URL("assets/icons/task-complete.gif", packageRootUrl)); +assert.equal(completeGif.subarray(0, 6).toString("ascii"), "GIF89a"); +assert.ok(completeGif.length > 0, "animated complete task icon must be packaged"); assert.ok( manifest.Actions.every(action => action.PropertyInspectorPath === "property-inspector/setup.html"), "every action must expose the shared Bridge setup inspector" ); +const modelActions = manifest.Actions.slice(-3); +assert.deepEqual( + modelActions.map(action => action.UUID), + [ + "com.ulanzi.ulanzistudio.codexmicro.model-sol-high", + "com.ulanzi.ulanzistudio.codexmicro.model-luna-max", + "com.ulanzi.ulanzistudio.codexmicro.model-sol-medium" + ] +); +for (const action of modelActions) { + assert.deepEqual(action.Controllers, ["Keypad"]); + assert.equal(action.DisableAutomaticStates, true); + assert.equal(action.States?.[0]?.Image, action.Icon); + assert.ok((await readFile(new URL(action.Icon, packageRootUrl))).length > 0, `${action.Icon} must exist`); +} const setupInspector = await readFile(new URL("property-inspector/setup.html", packageRootUrl), "utf8"); assert.match(setupInspector, /Codex Bridge Setup/); assert.match(setupInspector, /Install \/ Repair/); @@ -55,6 +73,16 @@ const localizedActionNames = { "pt_PT.json": ["Tarefa Codex 1", "Tarefa recente e deslocamento", "Enviar para o Codex"], "es_ES.json": ["Tarea Codex 1", "Tarea reciente y desplazamiento", "Enviar a Codex"] }; +const localizedModelActionNames = { + "en.json": ["Codex Sol High", "Codex Luna Max", "Codex Sol Medium"], + "zh_CN.json": ["Codex Sol 高", "Codex Luna 最高", "Codex Sol 中"], + "zh_HK.json": ["Codex Sol 高", "Codex Luna 最高", "Codex Sol 中"], + "ja_JP.json": ["Codex Sol High", "Codex Luna Max", "Codex Sol Medium"], + "de_DE.json": ["Codex Sol Hoch", "Codex Luna Maximum", "Codex Sol Mittel"], + "ko_KR.json": ["Codex Sol 높음", "Codex Luna 최대", "Codex Sol 중간"], + "pt_PT.json": ["Codex Sol Alto", "Codex Luna Máximo", "Codex Sol Médio"], + "es_ES.json": ["Codex Sol Alto", "Codex Luna Máximo", "Codex Sol Medio"] +}; for (const locale of [ "en.json", @@ -89,6 +117,11 @@ for (const locale of [ localizedActionNames[locale], `${locale} action localization must follow manifest action order` ); + assert.deepEqual( + messages.Actions.slice(-3).map(action => action.Name), + localizedModelActionNames[locale], + `${locale} must localize all model presets in manifest order` + ); assert.equal(messages.Localization.BridgeApp, "Codex Bridge"); assert.doesNotMatch(messages.Localization.BridgeAppDescription, /Codex Bridge\.app/); } @@ -162,16 +195,16 @@ try { ); assert.equal(setupStatus?.payload?.status?.serviceOnline, true); assert.equal(setupStatus?.payload?.status?.cdpConnected, true); - assert.equal(setupStatus?.payload?.status?.bundledVersion, "0.5.0"); + assert.equal(setupStatus?.payload?.status?.bundledVersion, "0.6.0"); - const taskPaths = [ - "assets/icons/task-working.png", - "assets/icons/task-complete.png", - "assets/icons/task-attention.png", - "assets/icons/task-error.png", - "assets/icons/task-idle.png" + const taskImages = [ + { type: 2, field: "path", value: "assets/icons/task-working.png" }, + { type: 4, field: "gifpath", value: "assets/icons/task-complete.gif" }, + { type: 2, field: "path", value: "assets/icons/task-attention.png" }, + { type: 2, field: "path", value: "assets/icons/task-error.png" }, + { type: 2, field: "path", value: "assets/icons/task-idle.png" } ]; - for (let index = 0; index < taskPaths.length; index += 1) { + for (let index = 0; index < taskImages.length; index += 1) { client.send(JSON.stringify({ cmd: "add", uuid: `com.ulanzi.ulanzistudio.codexmicro.task${index + 1}`, @@ -181,14 +214,15 @@ try { })); } await new Promise(resolve => setTimeout(resolve, 700)); - for (let index = 0; index < taskPaths.length; index += 1) { + for (let index = 0; index < taskImages.length; index += 1) { const taskUuid = `com.ulanzi.ulanzistudio.codexmicro.task${index + 1}`; const state = messages.find(message => message.cmd === "state" && message.param?.statelist?.[0]?.uuid === taskUuid ); const item = state?.param?.statelist?.[0]; - assert.equal(item?.type, 2); - assert.equal(item?.path, taskPaths[index]); + const expected = taskImages[index]; + assert.equal(item?.type, expected.type); + assert.equal(item?.[expected.field], expected.value); assert.equal(item?.showtext, true); assert.equal(Object.hasOwn(item || {}, "state"), false, "task icon update must not send a state index"); } @@ -232,7 +266,10 @@ try { assert.equal(navigateState?.param?.statelist?.[0]?.path, "assets/icons/task-working.png"); assert.equal(navigateState?.param?.statelist?.[0]?.textdata, "Working task"); - const actions = ["fast", "pin", "new", "fork", "steer", "mic", "submit"]; + const actions = [ + "fast", "pin", "new", "fork", "steer", "mic", "submit", + "model-sol-high", "model-luna-max", "model-sol-medium" + ]; for (const [index, action] of actions.entries()) { const uuid = `com.ulanzi.ulanzistudio.codexmicro.${action}`; const event = { uuid, actionid: `action-${action}`, key: `1_${index}`, param: {} }; diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json index 6aa0a7a..5b68574 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "通过 Ulanzi Studio,在 Ulanzi D200 Series 上查看并控制 Codex Desktop。", - "Description": "通过 Ulanzi Studio,在 Ulanzi D200 Series 上查看和控制 Codex Desktop。支持最近五个任务、Fast、Usage、Pin、New、Fork、Steer、Mic、Submit 和旋钮导航。支持 Windows 10 或更高版本以及 macOS 13 或更高版本。Windows 版 Bridge 会动态查找 Stable 或 Beta,Bridge 写操作使用本机能力令牌,CDP 仅监听回环地址。仓库安装请运行 npm install、npm run install:plugin 和 npm run setup。", + "Description": "通过 Ulanzi Studio,在 Ulanzi D200 Series 上查看和控制 Codex Desktop。支持最近五个任务、Fast、Usage、Pin、New、Fork、Steer、Mic、Submit、三个 GPT-5.6 模型预设和旋钮导航。支持 Windows 10 或更高版本以及 macOS 13 或更高版本。Windows 版 Bridge 会动态查找 Stable 或 Beta,Bridge 写操作使用本机能力令牌,CDP 仅监听回环地址。仓库安装请运行 npm install、npm run install:plugin 和 npm run setup。", "Actions": [ { "Name": "Codex 任务 1", "Tooltip": "打开最近的 Codex 任务槽位 1,并显示其实时状态。" }, { "Name": "Codex 任务 2", "Tooltip": "打开最近的 Codex 任务槽位 2,并显示其实时状态。" }, @@ -16,7 +16,10 @@ { "Name": "分支 Codex 任务", "Tooltip": "将当前 Codex 任务创建为新的分支任务。" }, { "Name": "引导 Codex", "Tooltip": "将当前编辑器文本作为引导输入发送给正在运行的任务。" }, { "Name": "Codex 麦克风", "Tooltip": "切换 Codex 麦克风操作。" }, - { "Name": "提交到 Codex", "Tooltip": "提交或排队发送当前 Codex 编辑器文本。" } + { "Name": "提交到 Codex", "Tooltip": "提交或排队发送当前 Codex 编辑器文本。" }, + { "Name": "Codex Sol 高", "Tooltip": "将当前可见的 Codex 任务切换到 GPT-5.6 Sol,并使用高推理强度。" }, + { "Name": "Codex Luna 最高", "Tooltip": "将当前可见的 Codex 任务切换到 GPT-5.6 Luna,并使用最高推理强度。" }, + { "Name": "Codex Sol 中", "Tooltip": "将当前可见的 Codex 任务切换到 GPT-5.6 Sol,并使用中等推理强度。" } ], "Localization": { "BridgeSetup": "Codex Bridge 设置", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_HK.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_HK.json index d7752b4..87edd92 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_HK.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_HK.json @@ -1,7 +1,7 @@ { "Name": "Codex Micro", "Overview": "透過 Ulanzi Studio,在 Ulanzi D200 Series 上查看並控制 Codex Desktop。", - "Description": "透過 Ulanzi Studio,在 Ulanzi D200 Series 上查看及控制 Codex Desktop。支援最近五個任務、Fast、Usage、Pin、New、Fork、Steer、Mic、Submit 及旋鈕導覽。支援 Windows 10 或更新版本及 macOS 13 或更新版本。Windows Bridge 會動態尋找 Stable 或 Beta,Bridge 寫入操作使用本機能力權杖,CDP 只監聽迴環位址。儲存庫安裝請執行 npm install、npm run install:plugin 及 npm run setup。", + "Description": "透過 Ulanzi Studio,在 Ulanzi D200 Series 上查看及控制 Codex Desktop。支援最近五個任務、Fast、Usage、Pin、New、Fork、Steer、Mic、Submit、三個 GPT-5.6 模型預設及旋鈕導覽。支援 Windows 10 或更新版本及 macOS 13 或更新版本。Windows Bridge 會動態尋找 Stable 或 Beta,Bridge 寫入操作使用本機能力權杖,CDP 只監聽迴環位址。儲存庫安裝請執行 npm install、npm run install:plugin 及 npm run setup。", "Actions": [ { "Name": "Codex 任務 1", "Tooltip": "開啟最近的 Codex 任務欄位 1,並顯示即時狀態。" }, { "Name": "Codex 任務 2", "Tooltip": "開啟最近的 Codex 任務欄位 2,並顯示即時狀態。" }, @@ -16,7 +16,10 @@ { "Name": "分支 Codex 任務", "Tooltip": "從目前 Codex 任務建立新的分支任務。" }, { "Name": "引導 Codex", "Tooltip": "將目前編輯器文字作為引導輸入傳送給執行中的任務。" }, { "Name": "Codex 麥克風", "Tooltip": "切換 Codex 麥克風操作。" }, - { "Name": "提交至 Codex", "Tooltip": "提交或排隊傳送目前的 Codex 編輯器文字。" } + { "Name": "提交至 Codex", "Tooltip": "提交或排隊傳送目前的 Codex 編輯器文字。" }, + { "Name": "Codex Sol 高", "Tooltip": "將目前可見的 Codex 任務切換至 GPT-5.6 Sol,並使用高推理強度。" }, + { "Name": "Codex Luna 最高", "Tooltip": "將目前可見的 Codex 任務切換至 GPT-5.6 Luna,並使用最高推理強度。" }, + { "Name": "Codex Sol 中", "Tooltip": "將目前可見的 Codex 任務切換至 GPT-5.6 Sol,並使用中等推理強度。" } ], "Localization": { "BridgeSetup": "Codex Bridge 設定", diff --git a/package-lock.json b/package-lock.json index 1368a2b..e2b8a74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-codex-micro", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-codex-micro", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "devDependencies": { "esbuild": "^0.25.12", diff --git a/package.json b/package.json index b9cf5b0..eb6a983 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-codex-micro", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "description": "Control Codex Desktop from Ulanzi Studio through a local bridge", diff --git a/src/bridge/codex-cdp.mjs b/src/bridge/codex-cdp.mjs index 7467f48..e9d7cb2 100644 --- a/src/bridge/codex-cdp.mjs +++ b/src/bridge/codex-cdp.mjs @@ -18,7 +18,12 @@ const MICRO_ACTION_KEYS = Object.freeze({ mic: "ACT10", submit: "ACT12" }); -const RENDERER_ACTIONS = new Set(["pin", "new"]); +const MODEL_PRESETS = Object.freeze({ + "model-sol-high": Object.freeze({ model: "gpt-5.6-sol", displayName: "5.6 Sol", effort: "high" }), + "model-luna-max": Object.freeze({ model: "gpt-5.6-luna", displayName: "5.6 Luna", effort: "max" }), + "model-sol-medium": Object.freeze({ model: "gpt-5.6-sol", displayName: "5.6 Sol", effort: "medium" }) +}); +const RENDERER_ACTIONS = new Set(["pin", "new", ...Object.keys(MODEL_PRESETS)]); const PIN_ACTION_LABELS = Object.freeze([ "Pin chat", "Unpin chat", @@ -88,6 +93,12 @@ export function rendererActionExpression(action) { })()`; } +export function modelPreset(action) { + const preset = MODEL_PRESETS[action]; + if (!preset) throw new Error(`Unknown Codex model preset: ${action}`); + return preset; +} + export function composerSteerExpression() { return `(() => { const editor = [...document.querySelectorAll('[contenteditable="true"][role="textbox"]')] @@ -481,11 +492,210 @@ export class CodexCdpClient { async dispatchRendererAction(action) { await this.connect(); + if (MODEL_PRESETS[action]) { + await this.dispatchModelPreset(action); + return true; + } const invoked = await this.evaluate(rendererActionExpression(action)); if (!invoked) throw new Error(`Codex ${action} action is not available`); return true; } + async dispatchModelPreset(action) { + const preset = MODEL_PRESETS[action]; + if (!preset) throw new Error(`Unknown Codex model preset: ${action}`); + const effortOrder = ["low", "medium", "high", "xhigh", "max"]; + const targetEffortIndex = effortOrder.indexOf(preset.effort); + const readState = async () => { + const state = await this.evaluate(`(() => { + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + const triggers = [...document.querySelectorAll("[data-codex-intelligence-trigger]")].filter(visible); + if (triggers.length !== 1) return { error: \`Expected one visible intelligence trigger, found \${triggers.length}\` }; + return { + text: String(triggers[0].textContent ?? "").replace(/\\s+/g, " ").trim(), + effort: triggers[0].getAttribute("data-selected-reasoning-effort"), + expanded: triggers[0].getAttribute("aria-expanded") === "true" + }; + })()`); + if (state?.error) throw new Error(state.error); + return state; + }; + const closeMenus = async () => { + for (let attempt = 0; attempt < 3 && (await readState()).expanded; attempt += 1) { + await this.pressRendererEscape(); + } + if ((await readState()).expanded) throw new Error("Codex intelligence menu did not close"); + }; + const openMain = async () => { + if (!(await readState()).expanded) { + await this.clickRendererCandidates( + '[...document.querySelectorAll("[data-codex-intelligence-trigger]")]', + "Codex intelligence trigger" + ); + } + await this.waitForRenderer(`(() => { + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + return [...document.querySelectorAll('[role="menu"][data-state="open"]')].filter( + (menu) => visible(menu) && ( + menu.querySelector("[data-model-picker-view-toggle]") || + menu.querySelector("[data-reasoning-slider]") + ) + ).length === 1; + })()`, "Codex intelligence menu"); + const toggleState = await this.evaluate(`(() => { + const menus = [...document.querySelectorAll('[role="menu"][data-state="open"]')].filter( + (menu) => menu.querySelector("[data-model-picker-view-toggle]") || menu.querySelector("[data-reasoning-slider]") + ); + const toggles = menus.length === 1 + ? [...menus[0].querySelectorAll("[data-model-picker-view-toggle]")] + : []; + return { + count: toggles.length, + expanded: toggles[0]?.getAttribute("aria-expanded") === "true", + rowCount: menus[0]?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0 + }; + })()`); + if (toggleState.count === 0 && toggleState.rowCount === 2) return; + if (toggleState.count !== 1) { + throw new Error(`Expected one model picker view toggle, found ${toggleState.count}`); + } + if (!toggleState.expanded) { + await this.clickRendererCandidates( + `(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return menu ? [...menu.querySelectorAll("[data-model-picker-view-toggle]")] : []; + })()`, + "model picker view toggle" + ); + } + }; + const rowExpression = (rowIndex) => `(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + return rows[${rowIndex}] ? [rows[${rowIndex}]] : []; + })()`; + const submenuInfo = async (rowIndex) => this.waitForRenderer(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + if (rows.length !== 2 || !rows[${rowIndex}]) return null; + const submenu = document.getElementById(rows[${rowIndex}].getAttribute("aria-controls")); + if (!submenu || submenu.getAttribute("data-state") !== "open") return null; + return [...submenu.querySelectorAll('[role="menuitem"]')].map((item) => ({ + text: String(item.textContent ?? "").replace(/\\s+/g, " ").trim(), + checked: Boolean(item.querySelector("svg")) + })); + })()`, "Codex model picker submenu"); + const identifyRows = async () => { + const rowCount = await this.evaluate(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return menu?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0; + })()`); + if (rowCount !== 2) throw new Error(`Expected two Codex model picker rows, found ${rowCount}`); + let modelRowIndex = -1; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + await openMain(); + await this.clickRendererCandidates(rowExpression(rowIndex), `model picker row ${rowIndex + 1}`); + const items = await submenuInfo(rowIndex); + if (items.filter((item) => item.text === preset.displayName).length === 1) { + modelRowIndex = rowIndex; + } + await this.pressRendererEscape(); + } + if (modelRowIndex < 0) throw new Error(`Codex model ${preset.displayName} is not available`); + return { modelRowIndex, effortRowIndex: modelRowIndex === 0 ? 1 : 0 }; + }; + const selectEffort = async () => { + const current = await readState(); + if (current.effort === preset.effort) return; + const currentEffortIndex = effortOrder.indexOf(current.effort); + if (currentEffortIndex < 0 || targetEffortIndex < 0) { + throw new Error(`Unsupported Codex reasoning effort transition: ${current.effort} -> ${preset.effort}`); + } + await openMain(); + const { effortRowIndex } = await identifyRows(); + await openMain(); + await this.clickRendererCandidates(rowExpression(effortRowIndex), "reasoning effort row"); + const items = await submenuInfo(effortRowIndex); + const checkedIndexes = items.flatMap((item, index) => item.checked ? [index] : []); + if (items.length !== effortOrder.length || checkedIndexes.length !== 1 || checkedIndexes[0] !== currentEffortIndex) { + throw new Error("Codex reasoning effort order or selected state changed"); + } + await this.clickRendererCandidates(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + const submenu = rows[${effortRowIndex}] + ? document.getElementById(rows[${effortRowIndex}].getAttribute("aria-controls")) + : null; + const items = submenu ? [...submenu.querySelectorAll('[role="menuitem"]')] : []; + return items[${targetEffortIndex}] ? [items[${targetEffortIndex}]] : []; + })()`, `reasoning effort ${preset.effort}`); + await this.waitForRenderer( + `document.querySelector("[data-codex-intelligence-trigger]")?.getAttribute("data-selected-reasoning-effort") === ${JSON.stringify(preset.effort)}`, + `reasoning effort ${preset.effort}` + ); + }; + const selectModel = async () => { + if ((await readState()).text.includes(preset.displayName)) return; + await openMain(); + const { modelRowIndex } = await identifyRows(); + await openMain(); + await this.clickRendererCandidates(rowExpression(modelRowIndex), "model row"); + const items = await submenuInfo(modelRowIndex); + if (items.filter((item) => item.text === preset.displayName).length !== 1) { + throw new Error(`Expected one available ${preset.displayName} model option`); + } + await this.clickRendererCandidates(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + const submenu = rows[${modelRowIndex}] + ? document.getElementById(rows[${modelRowIndex}].getAttribute("aria-controls")) + : null; + return submenu + ? [...submenu.querySelectorAll('[role="menuitem"]')].filter( + (item) => String(item.textContent ?? "").replace(/\\s+/g, " ").trim() === ${JSON.stringify(preset.displayName)} + ) + : []; + })()`, `model ${preset.displayName}`); + await this.waitForRenderer( + `String(document.querySelector("[data-codex-intelligence-trigger]")?.textContent ?? "").includes(${JSON.stringify(preset.displayName)})`, + `model ${preset.displayName}` + ); + }; + + try { + await selectEffort(); + await selectModel(); + await selectEffort(); + const selected = await readState(); + if (!selected.text.includes(preset.displayName) || selected.effort !== preset.effort) { + throw new Error(`Codex did not select ${preset.displayName} / ${preset.effort}`); + } + await closeMenus(); + return { model: preset.model, effort: preset.effort }; + } catch (error) { + try { await closeMenus(); } catch {} + throw error; + } + } + async dispatchComposerSteer() { await this.connect(); const clicked = await this.evaluate(composerSteerExpression()); @@ -564,7 +774,7 @@ export class CodexCdpClient { })()`); } - evaluate(expression) { + sendCommand(method, params, returnValue = false) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { return Promise.reject(new Error("Codex bridge is disconnected")); } @@ -574,15 +784,67 @@ export class CodexCdpClient { this.pending.delete(id); reject(new Error("Codex runtime response timed out")); }, 7000); - this.pending.set(id, { resolve, reject, timer }); + this.pending.set(id, { resolve, reject, timer, returnValue }); this.socket.send(JSON.stringify({ id, - method: "Runtime.evaluate", - params: { expression, awaitPromise: true, returnByValue: true } + method, + params })); }); } + evaluate(expression) { + return this.sendCommand("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true + }, true); + } + + async clickRendererCandidates(candidatesExpression, description) { + await this.evaluate(`(() => { + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + const candidates = [...(${candidatesExpression})].filter(visible); + if (candidates.length !== 1) { + throw new Error(\`Expected one ${description}, found \${candidates.length}\`); + } + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + const EventType = type.startsWith("pointer") ? PointerEvent : MouseEvent; + candidates[0].dispatchEvent(new EventType(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type.endsWith("down") ? 1 : 0, + view: window + })); + } + return true; + })()`); + await new Promise((resolve) => setTimeout(resolve, 80)); + } + + async pressRendererEscape() { + await this.evaluate(`(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "Escape", code: "Escape", bubbles: true, cancelable: true + })); + return true; + })()`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + async waitForRenderer(expression, description) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const result = await this.evaluate(expression); + if (result) return result; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timed out waiting for ${description}`); + } + handleMessage(raw) { let message; try { message = JSON.parse(raw); } catch { return; } @@ -598,7 +860,9 @@ export class CodexCdpClient { ?? "Codex evaluation failed" )); } - pending.resolve(message.result?.result?.value); + pending.resolve( + pending.returnValue ? message.result?.result?.value : message.result + ); } disconnect() { diff --git a/src/bridge/server.mjs b/src/bridge/server.mjs index ddaf8e3..6666ddc 100644 --- a/src/bridge/server.mjs +++ b/src/bridge/server.mjs @@ -125,7 +125,7 @@ const server = createServer(async (request, response) => { } } const action = request.method === "POST" && url.pathname.match( - /^\/action\/(fast|approve|reject|pin|new|fork|mic|steer|submit)\/(down|up)$/ + /^\/action\/(fast|approve|reject|pin|new|fork|mic|steer|submit|model-sol-high|model-luna-max|model-sol-medium)\/(down|up)$/ ); if (action) { try { diff --git a/test/bridge_test.mjs b/test/bridge_test.mjs index 4b47b7b..3617c17 100644 --- a/test/bridge_test.mjs +++ b/test/bridge_test.mjs @@ -11,6 +11,7 @@ import { import { CodexCdpClient, composerSteerExpression, + modelPreset, rendererActionExpression } from "../src/bridge/codex-cdp.mjs"; import { @@ -194,16 +195,27 @@ test("named Micro actions preserve press and release phases", async () => { } }); -test("renderer actions execute once on key down", async () => { +test("renderer actions and model presets execute once on key down", async () => { const client = new CodexCdpClient(); const calls = []; client.dispatchRendererAction = async (action) => calls.push(action); - for (const action of ["pin", "new"]) { + for (const action of ["pin", "new", "model-sol-high", "model-luna-max", "model-sol-medium"]) { await client.dispatchNamedAction(action, true); await client.dispatchNamedAction(action, false); } - assert.deepEqual(calls, ["pin", "new"]); + assert.deepEqual(calls, ["pin", "new", "model-sol-high", "model-luna-max", "model-sol-medium"]); +}); + +test("model presets freeze the requested model and effort", () => { + for (const [action, model, displayName, effort] of [ + ["model-sol-high", "gpt-5.6-sol", "5.6 Sol", "high"], + ["model-luna-max", "gpt-5.6-luna", "5.6 Luna", "max"], + ["model-sol-medium", "gpt-5.6-sol", "5.6 Sol", "medium"] + ]) { + assert.deepEqual(modelPreset(action), { model, displayName, effort }); + } + assert.throws(() => modelPreset("unknown"), /Unknown Codex model preset/); }); test("new renderer action accepts the current localized New conversation control", () => { From 4ef0d063406288881498295c7ec720b016aa04c7 Mon Sep 17 00:00:00 2001 From: yyf <2358929807@qq.com> Date: Fri, 21 Aug 2026 12:57:46 +0800 Subject: [PATCH 5/8] feat: improve task key status display --- docs/configuration.md | 9 +- docs/errors.md | 3 +- .../plugin/app.js | 145 ++++++++++++++---- .../test/smoke.mjs | 63 +++++--- 4 files changed, 168 insertions(+), 52 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ee9180f..a21b857 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,10 +27,11 @@ No repository or npm working directory is required for this flow. | Luna Max | Switch the visible task to GPT-5.6 Luna with maximum reasoning effort | | Sol Medium | Switch the visible task to GPT-5.6 Sol with medium reasoning effort | -Task actions dynamically use the plugin's idle, working, complete, attention, -error, and offline artwork. Usage is rendered at runtime from the allowance -returned by the Bridge. The Encoder action mirrors task 1's current title and -status artwork. +Task actions render a compact 196×196 SVG at runtime. A flat background color +identifies idle, working, complete, attention, or error state, while the task +title is balanced across at most two centered lines in a dark lower panel. +Usage is rendered at runtime from the allowance returned by the Bridge. The +Encoder action mirrors task 1's current title and status color. Model presets operate the visible Codex Desktop intelligence picker. They validate the target model, the current reasoning-effort ordering, and the final diff --git a/docs/errors.md b/docs/errors.md index 94d8dcc..71aa1aa 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -32,7 +32,8 @@ - Keep the last usable display during routine refreshes; use offline artwork only when the Bridge is actually unavailable. -- Task updates use relative-path icons and must not send a state index when the +- Task updates use self-contained runtime SVG data, draw the title into the + image, and must not send either a host title overlay or a state index when the manifest disables automatic states. - Usage refreshes must not block task input or action dispatch. - Runtime assets referenced by the manifest or plugin code must exist in the diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js index 38db1a6..e90f608 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js @@ -32,13 +32,17 @@ const ACTION_LABELS = Object.freeze({ "model-luna-max": "LUNA MAX", "model-sol-medium": "SOL MED" }); -const TASK_ICON_PATHS = Object.freeze({ - idle: "assets/icons/task-idle.png", - working: "assets/icons/task-working.png", - complete: "assets/icons/task-complete.gif", - attention: "assets/icons/task-attention.png", - error: "assets/icons/task-error.png" +const TASK_STATUS_COLORS = Object.freeze({ + idle: "#667085", + working: "#2589f5", + complete: "#28b875", + attention: "#ed9f20", + error: "#e34d62" }); +const TASK_TITLE_MAX_UNITS = 14; +const TASK_TITLE_SEGMENTER = typeof Intl.Segmenter === "function" + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; let socket; let reconnectTimer; @@ -191,18 +195,102 @@ async function handleBridgeSetupMessage(message) { await sendBridgeSetupStatus(message, { result, error: failure }); } -function taskIconPath(status) { +function taskStatusKind(status) { const value = String(status || "").toLowerCase(); - if (["working", "thinking", "running", "in_progress"].includes(value)) return TASK_ICON_PATHS.working; - if (["unread", "complete", "completed", "done", "success"].includes(value)) return TASK_ICON_PATHS.complete; - if (["attention", "notification", "input", "approval", "waiting_input", "needs_input"].includes(value)) return TASK_ICON_PATHS.attention; - if (["error", "failed", "failure"].includes(value)) return TASK_ICON_PATHS.error; - return TASK_ICON_PATHS.idle; + if (["working", "thinking", "running", "in_progress"].includes(value)) return "working"; + if (["unread", "complete", "completed", "done", "success"].includes(value)) return "complete"; + if (["attention", "notification", "input", "approval", "waiting_input", "needs_input"].includes(value)) return "attention"; + if (["error", "failed", "failure"].includes(value)) return "error"; + return "idle"; } -function shortTitle(value) { - const title = String(value || "Untitled").replace(/\s+/g, " ").trim(); - return title.length > 18 ? `${title.slice(0, 17)}…` : title; +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function titleGraphemes(value) { + const title = String(value || "Untitled").replace(/\s+/g, " ").trim() || "Untitled"; + if (!TASK_TITLE_SEGMENTER) return Array.from(title); + return Array.from(TASK_TITLE_SEGMENTER.segment(title), item => item.segment); +} + +function graphemeUnits(value) { + if (/^\s$/u.test(value)) return 0.55; + if (/^\p{Mark}+$/u.test(value)) return 0; + if (/\p{Extended_Pictographic}/u.test(value) || /[\u1100-\u11ff\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff\ufe10-\ufe6f\uff01-\uff60\uffe0-\uffe6]/u.test(value)) return 2; + if (/^[ilI1.,'`:;|!\[\](){}]$/u.test(value)) return 0.55; + if (/^[mwMW@#%&]$/u.test(value)) return 1.35; + return 1; +} + +function lineUnits(values) { + return values.reduce((total, value) => total + graphemeUnits(value), 0); +} + +function trimLine(values) { + const result = [...values]; + while (result[0] === " ") result.shift(); + while (result.at(-1) === " ") result.pop(); + return result; +} + +function taskTitleLines(value) { + let graphemes = titleGraphemes(value); + if (lineUnits(graphemes) <= TASK_TITLE_MAX_UNITS) return [graphemes.join("")]; + + const maxTotalUnits = TASK_TITLE_MAX_UNITS * 2; + if (lineUnits(graphemes) > maxTotalUnits) { + const ellipsisUnits = graphemeUnits("…"); + const clipped = []; + let used = 0; + for (const grapheme of graphemes) { + const units = graphemeUnits(grapheme); + if (used + units + ellipsisUnits > maxTotalUnits) break; + clipped.push(grapheme); + used += units; + } + graphemes = trimLine(clipped); + graphemes.push("…"); + } + + let best = null; + for (let index = 1; index < graphemes.length; index += 1) { + const first = trimLine(graphemes.slice(0, index)); + const second = trimLine(graphemes.slice(index)); + if (!first.length || !second.length) continue; + const firstUnits = lineUnits(first); + const secondUnits = lineUnits(second); + if (firstUnits > TASK_TITLE_MAX_UNITS || secondUnits > TASK_TITLE_MAX_UNITS) continue; + const breaksAtSpace = graphemes[index - 1] === " " || graphemes[index] === " "; + const score = Math.abs(firstUnits - secondUnits) + (breaksAtSpace ? 0 : 0.4); + if (!best || score < best.score) best = { first, second, score }; + } + + if (best) return [best.first.join(""), best.second.join("")]; + + const first = []; + while (graphemes.length && lineUnits([...first, graphemes[0]]) <= TASK_TITLE_MAX_UNITS) { + first.push(graphemes.shift()); + } + return [trimLine(first).join(""), trimLine(graphemes).join("")].filter(Boolean); +} + +function taskIconData(kind, lines) { + const color = TASK_STATUS_COLORS[kind]; + const fontSize = lines.length === 1 ? 25 : 22; + const lineY = lines.length === 1 ? [151] : [132, 162]; + const text = lines.map((line, index) => `${escapeXml(line)}`).join(""); + const svg = ` + + + ${text} + `; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; } function setDisplay(instance, state, text) { @@ -225,12 +313,12 @@ function setDisplay(instance, state, text) { }); } -function setTaskDisplay(instance, path, text) { - const isGif = path.toLowerCase().endsWith(".gif"); - const digest = `${isGif ? "gif" : "path"}:${path}:${text}`; +function setTaskDisplay(instance, status, title) { + const kind = taskStatusKind(status); + const lines = taskTitleLines(title); + const digest = `task:${kind}:${lines.join("\n")}`; if (!instance.active || instance.lastDisplay === digest) return; instance.lastDisplay = digest; - const image = isGif ? { type: 4, gifpath: path } : { type: 2, path }; send({ cmd: "state", param: { @@ -238,9 +326,10 @@ function setTaskDisplay(instance, path, text) { uuid: instance.uuid, actionid: instance.actionid, key: instance.key, - ...image, - showtext: true, - textdata: text + type: 1, + data: taskIconData(kind, lines), + showtext: false, + textdata: "" }] } }); @@ -273,7 +362,7 @@ function renderInstance(instance) { const action = actionName(instance.uuid); if (!latestState?.connected) { if (action === "navigate") { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Bridge Offline"); + setTaskDisplay(instance, "idle", "Bridge Offline"); } else { setDisplay(instance, 0, "Bridge Offline"); } @@ -286,25 +375,25 @@ function renderInstance(instance) { if (action === "navigate") { const task = latestState.slots?.[0]; if (!task?.threadKey) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Latest Task"); + setTaskDisplay(instance, "idle", "Latest Task"); return; } - setTaskDisplay(instance, taskIconPath(task.status), shortTitle(task.title)); + setTaskDisplay(instance, task.status, task.title); return; } setDisplay(instance, 0, ACTION_LABELS[action] || "CODEX"); return; } if (!latestState?.connected) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Bridge Offline"); + setTaskDisplay(instance, "idle", "Bridge Offline"); return; } const task = latestState.slots?.[slot]; if (!task?.threadKey) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, `Task ${slot + 1}`); + setTaskDisplay(instance, "idle", `Task ${slot + 1}`); return; } - setTaskDisplay(instance, taskIconPath(task.status), shortTitle(task.title)); + setTaskDisplay(instance, task.status, task.title); } function renderAll() { diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs index 53f2641..7fd3539 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs @@ -138,8 +138,8 @@ const bridge = createServer((request, response) => { response.end(JSON.stringify({ connected: true, slots: [ - { id: 0, threadKey: "11111111-1111-1111-1111-111111111111", title: "Working task", status: "thinking" }, - { id: 1, threadKey: "22222222-2222-2222-2222-222222222222", title: "Unread task", status: "unread" }, + { id: 0, threadKey: "11111111-1111-1111-1111-111111111111", title: "排查 PowerShell 配置与完整路径问题", status: "thinking" }, + { id: 1, threadKey: "22222222-2222-2222-2222-222222222222", title: "A&B \"D\" 'E'", status: "unread" }, { id: 2, threadKey: "33333333-3333-3333-3333-333333333333", title: "Input task", status: "input" }, { id: 3, threadKey: "44444444-4444-4444-4444-444444444444", title: "Failed task", status: "error" }, { id: 4, threadKey: "55555555-5555-5555-5555-555555555555", title: "Idle task", status: "idle" } @@ -197,14 +197,14 @@ try { assert.equal(setupStatus?.payload?.status?.cdpConnected, true); assert.equal(setupStatus?.payload?.status?.bundledVersion, "0.6.0"); - const taskImages = [ - { type: 2, field: "path", value: "assets/icons/task-working.png" }, - { type: 4, field: "gifpath", value: "assets/icons/task-complete.gif" }, - { type: 2, field: "path", value: "assets/icons/task-attention.png" }, - { type: 2, field: "path", value: "assets/icons/task-error.png" }, - { type: 2, field: "path", value: "assets/icons/task-idle.png" } + const taskColors = [ + "#2589f5", + "#28b875", + "#ed9f20", + "#e34d62", + "#667085" ]; - for (let index = 0; index < taskImages.length; index += 1) { + for (let index = 0; index < taskColors.length; index += 1) { client.send(JSON.stringify({ cmd: "add", uuid: `com.ulanzi.ulanzistudio.codexmicro.task${index + 1}`, @@ -214,22 +214,42 @@ try { })); } await new Promise(resolve => setTimeout(resolve, 700)); - for (let index = 0; index < taskImages.length; index += 1) { + const taskSvgs = []; + for (let index = 0; index < taskColors.length; index += 1) { const taskUuid = `com.ulanzi.ulanzistudio.codexmicro.task${index + 1}`; const state = messages.find(message => message.cmd === "state" && message.param?.statelist?.[0]?.uuid === taskUuid ); const item = state?.param?.statelist?.[0]; - const expected = taskImages[index]; - assert.equal(item?.type, expected.type); - assert.equal(item?.[expected.field], expected.value); - assert.equal(item?.showtext, true); + assert.equal(item?.type, 1); + assert.match(item?.data || "", /^data:image\/svg\+xml;base64,/); + assert.equal(item?.showtext, false); + assert.equal(item?.textdata, ""); + assert.equal(Object.hasOwn(item || {}, "path"), false); + assert.equal(Object.hasOwn(item || {}, "gifpath"), false); assert.equal(Object.hasOwn(item || {}, "state"), false, "task icon update must not send a state index"); + const svg = Buffer.from(item.data.split(",")[1], "base64").toString(); + taskSvgs.push(svg); + assert.match(svg, new RegExp(taskColors[index])); + assert.match(svg, /width="196" height="196"/); + assert.match(svg, /text-anchor="middle"/); } - const task1State = messages.find(message => - message.cmd === "state" && message.param?.statelist?.[0]?.uuid.endsWith(".task1") + const balancedLines = Array.from(taskSvgs[0].matchAll(/]*>(.*?)<\/tspan>/g), match => match[1]); + assert.equal(balancedLines.length, 2, "long mixed title must render on exactly two lines"); + assert.ok(balancedLines.every(line => line.length > 0)); + const visibleUnits = line => Array.from(line).reduce((total, character) => + total + (/[\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff]/u.test(character) ? 2 : character === " " ? 0.5 : 1), 0 ); - assert.equal(task1State?.param?.statelist?.[0]?.textdata, "Working task"); + assert.ok( + Math.abs(visibleUnits(balancedLines[0]) - visibleUnits(balancedLines[1])) <= 3, + "mixed-language title lines must have similar visual widths" + ); + assert.match(balancedLines[1], /…$/, "truncated title must add an ellipsis to the second line"); + assert.match(taskSvgs[1], /A&B/); + assert.match(taskSvgs[1], /<C>/); + assert.match(taskSvgs[1], /"D"/); + assert.match(taskSvgs[1], /'E'/); + assert.doesNotMatch(taskSvgs[1], //); client.send(JSON.stringify({ cmd: "keydown", uuid: "com.ulanzi.ulanzistudio.codexmicro.task1", actionid: "a1", key: "0_0", param: {} })); client.send(JSON.stringify({ cmd: "run", uuid: "com.ulanzi.ulanzistudio.codexmicro.task1", actionid: "a1", key: "0_0", param: {} })); @@ -263,8 +283,13 @@ try { message.cmd === "state" && message.param?.statelist?.[0]?.uuid === navigateEvent.uuid ); - assert.equal(navigateState?.param?.statelist?.[0]?.path, "assets/icons/task-working.png"); - assert.equal(navigateState?.param?.statelist?.[0]?.textdata, "Working task"); + const navigateItem = navigateState?.param?.statelist?.[0]; + assert.equal(navigateItem?.type, 1); + assert.equal(navigateItem?.showtext, false); + assert.equal(navigateItem?.textdata, ""); + const navigateSvg = Buffer.from(navigateItem.data.split(",")[1], "base64").toString(); + assert.match(navigateSvg, /#2589f5/); + assert.match(navigateSvg, /排查/); const actions = [ "fast", "pin", "new", "fork", "steer", "mic", "submit", From 12167b5cc82b31ec8d7e1082f4e27e88bc118aeb Mon Sep 17 00:00:00 2001 From: yyf <2358929807@qq.com> Date: Fri, 21 Aug 2026 12:57:46 +0800 Subject: [PATCH 6/8] feat: harden bridge updates and action reliability --- README.md | 7 +- README_zh.md | 3 +- docs/architecture.md | 18 +- docs/configuration.md | 8 +- docs/errors.md | 7 +- docs/setup-and-operations.md | 18 +- .../assets/icons/task-idle.svg | 9 + .../build.mjs | 6 +- .../manifest.json | 26 +- .../package-lock.json | 4 +- .../package.json | 2 +- .../plugin/app.js | 325 ++++++++++-- .../plugin/bridge-installer.js | 502 ++++++++++++++++-- .../test/smoke.mjs | 196 +++++-- package-lock.json | 4 +- package.json | 2 +- scripts/build-bridge.mjs | 6 +- scripts/install-plugin.mjs | 30 ++ src/bridge/codex-cdp.mjs | 292 ++++++++-- src/bridge/platform.mjs | 22 +- src/bridge/server.mjs | 166 +++++- test/bridge-installer_test.mjs | 440 ++++++++++++++- test/bridge_test.mjs | 89 +++- 23 files changed, 1929 insertions(+), 253 deletions(-) create mode 100644 integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-idle.svg diff --git a/README.md b/README.md index d06aa3d..743045d 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,10 @@ npm run setup ``` Quit Ulanzi Studio before running `npm run install:plugin`. The command -validates and atomically installs the prebuilt plugin. `npm run setup` builds -the loopback Bridge sidecar, registers its user LaunchAgent, and installs -the platform-specific user-level Bridge runtime. +validates and atomically installs the prebuilt plugin. If Bridge is already +installed, the command also verifies its version and runtime hash, then safely +updates and restarts it when needed. `npm run setup` performs the initial Bridge +installation or an explicit repair. Reopen Ulanzi Studio and use **Launch Codex Bridge** in any Action setup page, or run `npm run bridge:start`. On Windows the launcher discovers the current diff --git a/README_zh.md b/README_zh.md index 0e68de5..30dcbc5 100644 --- a/README_zh.md +++ b/README_zh.md @@ -82,7 +82,8 @@ npm run setup ``` 运行 `npm run install:plugin` 前请退出 Ulanzi Studio。该命令会校验并原子安装仓库 -中的预构建插件;`npm run setup` 会构建并安装当前平台的用户级 Bridge sidecar。 +中的预构建插件;若 Bridge 已安装,还会校验其版本与 runtime hash,并在需要时安全 +更新和重启。`npm run setup` 用于首次安装 Bridge 或显式修复。 重新打开 Ulanzi Studio,在任意 Action 设置页点击“启动 Codex Bridge”,或运行 `npm run bridge:start`。Windows 会从 Appx 包元数据动态发现 Stable 或 Beta, diff --git a/docs/architecture.md b/docs/architecture.md index c8f1e55..a5a7547 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,9 +43,21 @@ The plugin is a Node.js JavaScript plugin using protocol V3.0.0. It polls the Bridge state, keeps each Ulanzi action instance keyed by its context, updates task icons and titles, and forwards keydown/keyup events to the Bridge. +The installed Bridge is identified by plugin version plus the SHA-256 of its +runtime. `/health` reports the running version and runtime hash. Plugin startup +reconciles an existing Bridge when metadata, the installed file, or the running +process differs from the bundled runtime; replacement uses a targeted Bridge-only +stop, a cross-process lifecycle lock, a recoverable runtime swap, restart +confirmation, and rollback on failure. +Interrupted transactions restore runtime and metadata together before retrying; +plugin startup also applies bounded backoff for transient reconciliation errors. + The distributed entry point is the committed CommonJS file `dist/app.js`. Build and smoke checks happen during development; `scripts/install-plugin.mjs` -only validates and atomically copies the prebuilt `.ulanziPlugin` directory. +validates and atomically copies the prebuilt `.ulanziPlugin` directory, then +reconciles an already-installed Bridge before discarding the plugin backup. +Independent `install:plugin` processes share an owner-checked filesystem lock; +Bridge mutations use a separate owner-checked lifecycle lock. Ulanzi's implementation and maintenance scope is limited to `integration/com.ulanzi.codexmicro.ulanziPlugin/`. The plugin is only the @@ -57,5 +69,7 @@ development, or maintenance of CDP or Codex's CDP implementation. - `scripts/install.mjs` installs only the Bridge sidecar and its platform-specific user-level launcher/lifecycle files. -- `scripts/install-plugin.mjs` installs only the Ulanzi Studio plugin directory. +- `scripts/install-plugin.mjs` atomically installs the Ulanzi Studio plugin + directory and reconciles an already-installed Bridge to the bundled runtime. + It does not perform the initial Bridge installation. - `scripts/uninstall.mjs` removes those two installed components. diff --git a/docs/configuration.md b/docs/configuration.md index a21b857..9c2c621 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,9 +27,11 @@ No repository or npm working directory is required for this flow. | Luna Max | Switch the visible task to GPT-5.6 Luna with maximum reasoning effort | | Sol Medium | Switch the visible task to GPT-5.6 Sol with medium reasoning effort | -Task actions render a compact 196×196 SVG at runtime. A flat background color -identifies idle, working, complete, attention, or error state, while the task -title is balanced across at most two centered lines in a dark lower panel. +Task actions render a compact 196×196 SVG at runtime. A colored outer frame and +accent identify idle, blue working, flashing green complete, attention, or error +state. The high-contrast inner surface reserves most of the key for the task +title, which Ulanzi Studio overlays in as many as four short lines with safe +horizontal margins. Usage is rendered at runtime from the allowance returned by the Bridge. The Encoder action mirrors task 1's current title and status color. diff --git a/docs/errors.md b/docs/errors.md index 71aa1aa..c901087 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -32,9 +32,10 @@ - Keep the last usable display during routine refreshes; use offline artwork only when the Bridge is actually unavailable. -- Task updates use self-contained runtime SVG data, draw the title into the - image, and must not send either a host title overlay or a state index when the - manifest disables automatic states. +- Task updates use self-contained runtime SVG backgrounds and the protocol + `textData` overlay for the title. They must not send a state index when the + manifest disables automatic states. The legacy lowercase `textdata` field is + sent with the same value for compatibility with older raw-protocol hosts. - Usage refreshes must not block task input or action dispatch. - Runtime assets referenced by the manifest or plugin code must exist in the installed `.ulanziPlugin` directory. diff --git a/docs/setup-and-operations.md b/docs/setup-and-operations.md index fa65b80..66a7665 100644 --- a/docs/setup-and-operations.md +++ b/docs/setup-and-operations.md @@ -20,6 +20,15 @@ Bridge Setup** page, choose **Install / Repair**, then **Launch Codex Bridge**. The bundled installer requires no repository path, npm directory, administrator service, or fixed Codex package version. +Initial Bridge installation remains an explicit setup action. After that, every +plugin startup compares the bundled version and SHA-256 with both the installed +runtime and the identity reported by the running Bridge. A mismatch updates only +the managed Bridge files, restarts that Bridge process, and waits for the new +process to report the expected build; Codex Desktop is not terminated. +Interrupted updates restore both runtime and metadata backups and retry once in +the same lifecycle operation. Other transient startup failures use three bounded +background retries with backoff while Ulanzi Studio remains open. + Windows installs Bridge data under: ```text @@ -51,6 +60,9 @@ npm run setup Quit Ulanzi Studio before replacing a loaded plugin. `install:plugin` validates the manifest, entry point, Property Inspector, icons, banners, locales, and bundled Bridge resources before atomically replacing the installed directory. +When Bridge is already installed, the same command automatically verifies, +updates, and safely restarts it. A failed Bridge restart restores the previous +Bridge runtime and rolls the plugin directory back to its previous installation. Plugin destinations are: @@ -115,8 +127,10 @@ npm run setup npm run install:plugin ``` -Restart Ulanzi Studio after updating the plugin. Remove Bridge and the installed -plugin only when explicitly intended: +`install:plugin` now reconciles an existing Bridge automatically; `setup` remains +available for first installation or an explicit repair. Restart Ulanzi Studio +after updating the plugin. Remove Bridge and the installed plugin only when +explicitly intended: ```text npm run uninstall diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-idle.svg b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-idle.svg new file mode 100644 index 0000000..7723aed --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-idle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs index 965a6a7..e5f8733 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs @@ -1,10 +1,11 @@ -import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { build } from "esbuild"; import { resolve } from "node:path"; const packageRoot = resolve("."); const repositoryRoot = resolve(packageRoot, "../.."); const installerRoot = resolve(packageRoot, "installer"); +const manifest = JSON.parse(await readFile(resolve(packageRoot, "manifest.json"), "utf8")); await mkdir("dist", { recursive: true }); await build({ @@ -26,6 +27,9 @@ await build({ outfile: resolve(installerRoot, "bridge.mjs"), platform: "node", target: "node20", + define: { + __CODEX_BRIDGE_VERSION__: JSON.stringify(String(manifest.Version)) + }, banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json index 74d8b93..3b8204d 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json @@ -11,7 +11,7 @@ "assets/banners/codex-micro-banner-2.png", "assets/banners/codex-micro-banner-3.png" ], - "Version": "0.6.0", + "Version": "0.6.1", "UUID": "com.ulanzi.ulanzistudio.codexmicro", "Type": "JavaScript", "CodePath": "dist/app.js", @@ -31,53 +31,53 @@ "Actions": [ { "Name": "Codex Task 1", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task1", "Tooltip": "Open recent Codex task slot 1 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Task 2", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task2", "Tooltip": "Open recent Codex task slot 2 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Task 3", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task3", "Tooltip": "Open recent Codex task slot 3 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Task 4", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task4", "Tooltip": "Open recent Codex task slot 4 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Task 5", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.task5", "Tooltip": "Open recent Codex task slot 5 and show its live status.", "Controllers": ["Keypad"], "DisableAutomaticStates": true, - "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Codex Fast", @@ -121,14 +121,14 @@ }, { "Name": "Latest Task & Scroll", - "Icon": "assets/icons/task-idle.png", + "Icon": "assets/icons/task-idle.svg", "PropertyInspectorPath": "property-inspector/setup.html", "UUID": "com.ulanzi.ulanzistudio.codexmicro.navigate", "Tooltip": "Press to open the latest Codex task. Turn left to scroll up and right to scroll down.", "Controllers": ["Encoder"], "Encoder": { "layout": "$UA1" }, "DisableAutomaticStates": true, - "States": [{ "Name": "Latest Task", "Image": "assets/icons/task-idle.png" }] + "States": [{ "Name": "Latest Task", "Image": "assets/icons/task-idle.svg" }] }, { "Name": "Fork Codex Task", diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json index 5a87979..bc63827 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.6.0", + "version": "0.6.1", "dependencies": { "ws": "^8.18.0" }, diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json index 5c95ca9..650f416 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/package.json @@ -1,6 +1,6 @@ { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.6.0", + "version": "0.6.1", "private": true, "type": "module", "scripts": { diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js index e90f608..98d4d8c 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js @@ -1,4 +1,5 @@ import WebSocket from "ws"; +import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { createBridgeInstaller } from "./bridge-installer.js"; @@ -15,6 +16,49 @@ const bridgeSetup = createBridgeInstaller({ bridgeUrl: BRIDGE_URL, version: MANIFEST.Version }); +let bridgeReconcilePromise = null; +let bridgeReconcileRetryTimer = null; +let bridgeReconcileFailures = 0; + +function scheduleBridgeReconcileRetry() { + if (bridgeReconcileRetryTimer || bridgeReconcileFailures >= 3) return; + const delay = Math.min(10000, 2000 * (2 ** bridgeReconcileFailures)); + bridgeReconcileFailures += 1; + bridgeReconcileRetryTimer = setTimeout(() => { + bridgeReconcileRetryTimer = null; + void ensureBundledBridgeCurrent().catch(error => { + console.error(`[Codex Micro] Bridge reconciliation retry failed: ${error.message}`); + }); + }, delay); + bridgeReconcileRetryTimer.unref?.(); +} + +function ensureBundledBridgeCurrent() { + if (!bridgeReconcilePromise) { + const running = (async () => { + const before = await bridgeSetup.status(); + if (!before.installationDetected) return before; + if (before.needsUpdate) { + console.log(`[Codex Micro] Updating the installed Bridge to bundled version ${before.bundledVersion}.`); + } + const after = await bridgeSetup.ensureCurrent(); + if (before.needsUpdate) { + console.log(`[Codex Micro] Bridge ${after.installedVersion} restarted with the bundled runtime.`); + } + return after; + })(); + bridgeReconcilePromise = running; + void running.then(() => { + bridgeReconcileFailures = 0; + if (bridgeReconcileRetryTimer) clearTimeout(bridgeReconcileRetryTimer); + bridgeReconcileRetryTimer = null; + }, () => { + if (bridgeReconcilePromise === running) bridgeReconcilePromise = null; + scheduleBridgeReconcileRetry(); + }); + } + return bridgeReconcilePromise; +} const USAGE_BASE64 = readFileSync( resolve(PLUGIN_ROOT, "assets/icons/usage-base.png") ).toString("base64"); @@ -32,14 +76,21 @@ const ACTION_LABELS = Object.freeze({ "model-luna-max": "LUNA MAX", "model-sol-medium": "SOL MED" }); -const TASK_STATUS_COLORS = Object.freeze({ - idle: "#667085", - working: "#2589f5", - complete: "#28b875", - attention: "#ed9f20", - error: "#e34d62" +const DEBUG_MODEL_PRESETS = Object.freeze({ + "model-sol-high": Object.freeze({ model: "gpt-5.6-sol", effort: "high" }), + "model-luna-max": Object.freeze({ model: "gpt-5.6-luna", effort: "max" }), + "model-sol-medium": Object.freeze({ model: "gpt-5.6-sol", effort: "medium" }) +}); +const TASK_STATUS_PALETTES = Object.freeze({ + idle: { frame: "#475467", accent: "#98a2b3" }, + working: { frame: "#0b5fcc", accent: "#2589f5" }, + complete: { frame: "#087443", accent: "#28b875" }, + attention: { frame: "#9a6700", accent: "#ed9f20" }, + error: { frame: "#b42318", accent: "#e34d62" } }); -const TASK_TITLE_MAX_UNITS = 14; +const TASK_COMPLETE_FLASH_PALETTE = Object.freeze({ frame: "#12b76a", accent: "#6ce9a6" }); +const TASK_TITLE_MAX_UNITS = 10; +const TASK_TITLE_MAX_LINES = 4; const TASK_TITLE_SEGMENTER = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null; @@ -50,6 +101,9 @@ let pollTimer; let pollInFlight = false; let latestState = null; let setupOperation = null; +let completeFlashOn = false; +let shortcutInvocationSequence = 0; +const forwardedTraceEvents = new Map(); function contextOf(message) { return String(message.actionid || `${message.uuid}___${message.key}`); @@ -204,13 +258,98 @@ function taskStatusKind(status) { return "idle"; } -function escapeXml(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); +function debugShortcut(uuid) { + const slot = taskSlot(uuid); + if (slot !== null) { + return Object.freeze({ name: `task-${slot + 1}`, kind: "task", target: `slot-${slot + 1}` }); + } + const action = actionName(uuid); + const preset = DEBUG_MODEL_PRESETS[action]; + if (!preset) return null; + return Object.freeze({ + name: action, + kind: "model-preset", + target: `model-${preset.model} effort-${preset.effort}` + }); +} + +function shortcutErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timeout")) return "bridge-timeout"; + if (message.includes("is empty")) return "empty-task-slot"; + if (message.includes("fetch failed") || message.includes("econnrefused")) return "bridge-unavailable"; + if (message.includes("authorization") || message.includes("unauthorized") || message.includes("forbidden")) { + return "bridge-rejected"; + } + return "bridge-error"; +} + +function logShortcut(instance, shortcut, level, event, fields = []) { + const message = `[Codex Micro] shortcut event=${event} name=${shortcut.name} kind=${shortcut.kind} ${fields.join(" ")}`.trim(); + if (level === "error") console.error(message); + else if (level === "warn") console.warn(message); + else console.log(message); + send({ + cmd: "logMessage", + uuid: PLUGIN_UUID, + actionid: "", + key: "", + level, + message + }); +} + +function safeDiagnosticFields(event) { + const allowed = new Set([ + "action", "attempts", "background", "category", "complete", "connection", + "currentEffort", "durationMs", "effortMatched", "modelMatched", "outcome", + "path", "phase", "platform", "reused", "route", "rowCount", "slot", "stage", + "targetEffort" + ]); + return Object.entries(event || {}).flatMap(([key, value]) => { + if (!allowed.has(key)) return []; + if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) { + return [`${key}=${value}`]; + } + const normalized = String(value || ""); + return /^[a-zA-Z0-9_.:-]{1,80}$/.test(normalized) ? [`${key}=${normalized}`] : []; + }); +} + +async function forwardBridgeDiagnostics(instance, shortcut, traceId) { + try { + if (forwardedTraceEvents.get(traceId) === Infinity) return true; + const payload = await bridgeRequest("/diagnostics/trace", "POST", traceId); + const diagnostics = payload?.diagnostics; + if (!diagnostics || diagnostics.traceId !== traceId || !Array.isArray(diagnostics.events)) return false; + const seen = forwardedTraceEvents.get(traceId) || 0; + for (const event of diagnostics.events.slice(seen, 160)) { + const eventName = String(event?.event || ""); + if (!/^[a-z0-9.-]{1,80}$/.test(eventName)) continue; + logShortcut(instance, shortcut, event.outcome === "failed" ? "error" : "debug", "bridge-trace", [ + `trace=${traceId}`, + `bridgeEvent=${eventName}`, + `offsetMs=${Math.max(0, Math.round(Number(event.offsetMs) || 0))}`, + ...safeDiagnosticFields(event) + ]); + } + forwardedTraceEvents.set(traceId, diagnostics.events.length); + if (diagnostics.complete) forwardedTraceEvents.set(traceId, Infinity); + return Boolean(diagnostics.complete); + } catch { + return false; + } +} + +function collectBridgeDiagnostics(instance, shortcut, traceId) { + const delays = [0, 400, 1200, 2600, 5200]; + for (const delay of delays) { + const timer = setTimeout(() => void forwardBridgeDiagnostics(instance, shortcut, traceId), delay); + timer.unref?.(); + } + const cleanup = setTimeout(() => forwardedTraceEvents.delete(traceId), 10000); + cleanup.unref?.(); } function titleGraphemes(value) { @@ -241,9 +380,7 @@ function trimLine(values) { function taskTitleLines(value) { let graphemes = titleGraphemes(value); - if (lineUnits(graphemes) <= TASK_TITLE_MAX_UNITS) return [graphemes.join("")]; - - const maxTotalUnits = TASK_TITLE_MAX_UNITS * 2; + const maxTotalUnits = TASK_TITLE_MAX_UNITS * TASK_TITLE_MAX_LINES; if (lineUnits(graphemes) > maxTotalUnits) { const ellipsisUnits = graphemeUnits("…"); const clipped = []; @@ -258,37 +395,37 @@ function taskTitleLines(value) { graphemes.push("…"); } - let best = null; - for (let index = 1; index < graphemes.length; index += 1) { - const first = trimLine(graphemes.slice(0, index)); - const second = trimLine(graphemes.slice(index)); - if (!first.length || !second.length) continue; - const firstUnits = lineUnits(first); - const secondUnits = lineUnits(second); - if (firstUnits > TASK_TITLE_MAX_UNITS || secondUnits > TASK_TITLE_MAX_UNITS) continue; - const breaksAtSpace = graphemes[index - 1] === " " || graphemes[index] === " "; - const score = Math.abs(firstUnits - secondUnits) + (breaksAtSpace ? 0 : 0.4); - if (!best || score < best.score) best = { first, second, score }; + const lines = []; + while (graphemes.length && lines.length < TASK_TITLE_MAX_LINES) { + const line = []; + while (graphemes.length && lineUnits([...line, graphemes[0]]) <= TASK_TITLE_MAX_UNITS) { + line.push(graphemes.shift()); + } + if (!line.length) line.push(graphemes.shift()); + const trimmed = trimLine(line); + if (trimmed.length) lines.push(trimmed.join("")); + while (graphemes[0] === " ") graphemes.shift(); } - - if (best) return [best.first.join(""), best.second.join("")]; - - const first = []; - while (graphemes.length && lineUnits([...first, graphemes[0]]) <= TASK_TITLE_MAX_UNITS) { - first.push(graphemes.shift()); + if (graphemes.length && lines.length === TASK_TITLE_MAX_LINES && !lines.at(-1).endsWith("…")) { + const lastLine = titleGraphemes(lines.at(-1)); + while (lastLine.length && lineUnits([...lastLine, "…"]) > TASK_TITLE_MAX_UNITS) lastLine.pop(); + lines[lines.length - 1] = `${trimLine(lastLine).join("")}…`; } - return [trimLine(first).join(""), trimLine(graphemes).join("")].filter(Boolean); + return lines; } -function taskIconData(kind, lines) { - const color = TASK_STATUS_COLORS[kind]; - const fontSize = lines.length === 1 ? 25 : 22; - const lineY = lines.length === 1 ? [151] : [132, 162]; - const text = lines.map((line, index) => `${escapeXml(line)}`).join(""); +function taskIconData(kind, flashOn = false) { + const palette = kind === "complete" && flashOn + ? TASK_COMPLETE_FLASH_PALETTE + : TASK_STATUS_PALETTES[kind]; const svg = ` - - - ${text} + + + + + + + `; return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; } @@ -316,7 +453,9 @@ function setDisplay(instance, state, text) { function setTaskDisplay(instance, status, title) { const kind = taskStatusKind(status); const lines = taskTitleLines(title); - const digest = `task:${kind}:${lines.join("\n")}`; + const displayTitle = lines.join("\n"); + const flashOn = kind === "complete" && completeFlashOn; + const digest = `task:${kind}:${flashOn}:${displayTitle}`; if (!instance.active || instance.lastDisplay === digest) return; instance.lastDisplay = digest; send({ @@ -327,9 +466,10 @@ function setTaskDisplay(instance, status, title) { actionid: instance.actionid, key: instance.key, type: 1, - data: taskIconData(kind, lines), - showtext: false, - textdata: "" + data: taskIconData(kind, flashOn), + showtext: true, + textData: displayTitle, + textdata: displayTitle }] } }); @@ -400,10 +540,12 @@ function renderAll() { for (const instance of instances.values()) renderInstance(instance); } -async function bridgeRequest(path, method = "GET") { +async function bridgeRequest(path, method = "GET", traceId = null) { + const headers = { ...await bridgeSetup.authorizationHeaders() }; + if (traceId) headers["X-Codex-Trace-Id"] = traceId; const response = await fetch(`${BRIDGE_URL}${path}`, { method, - headers: await bridgeSetup.authorizationHeaders(), + headers, signal: AbortSignal.timeout(1200) }); const payload = await response.json(); @@ -413,10 +555,10 @@ async function bridgeRequest(path, method = "GET") { return payload; } -async function openTaskSlot(slot) { +async function openTaskSlot(slot, traceId) { const task = latestState?.slots?.[slot]; if (!task?.threadKey) throw new Error(`Codex task slot ${slot + 1} is empty`); - await bridgeRequest(`/thread/${encodeURIComponent(task.threadKey)}/click?slot=${slot}`, "POST"); + await bridgeRequest(`/thread/${encodeURIComponent(task.threadKey)}/click?slot=${slot}`, "POST", traceId); } async function pollBridge() { @@ -428,16 +570,53 @@ async function pollBridge() { latestState = { connected: false, error: error.message, slots: [] }; } finally { pollInFlight = false; + completeFlashOn = !completeFlashOn; renderAll(); } } async function invoke(instance, pressed) { const slot = taskSlot(instance.uuid); + const shortcut = debugShortcut(instance.uuid); + const invocation = shortcut ? ++shortcutInvocationSequence : null; + const traceId = shortcut ? randomUUID() : null; + const phase = pressed ? "down" : "up"; + const startedAt = Date.now(); + if (shortcut) { + logShortcut(instance, shortcut, "debug", "received", [ + `invocation=${invocation}`, + `trace=${traceId}`, + `phase=${phase}`, + `target=${shortcut.target}` + ]); + } try { if (slot !== null) { - if (!pressed) return; - await openTaskSlot(slot); + if (!pressed) { + if (shortcut) { + logShortcut(instance, shortcut, "debug", "ignored", [ + `invocation=${invocation}`, + `phase=${phase}`, + "reason=keydown-only" + ]); + } + return; + } + if (shortcut) { + logShortcut(instance, shortcut, "debug", "dispatching", [ + `invocation=${invocation}`, + `phase=${phase}`, + "transport=bridge-http" + ]); + } + await openTaskSlot(slot, traceId); + if (shortcut) { + logShortcut(instance, shortcut, "info", "succeeded", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}` + ]); + } return; } const action = actionName(instance.uuid); @@ -446,10 +625,42 @@ async function invoke(instance, pressed) { if (pressed) await bridgeRequest("/focus", "POST"); return; } - await bridgeRequest(`/action/${action}/${pressed ? "down" : "up"}`, "POST"); + if (shortcut) { + logShortcut(instance, shortcut, "debug", "dispatching", [ + `invocation=${invocation}`, + `phase=${phase}`, + "transport=bridge-http" + ]); + } + await bridgeRequest(`/action/${action}/${pressed ? "down" : "up"}`, "POST", traceId); + if (shortcut) { + logShortcut(instance, shortcut, "info", "succeeded", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}` + ]); + } } catch (error) { - send({ cmd: "logMessage", uuid: instance.uuid, actionid: instance.actionid, key: instance.key, level: "error", message: error.message }); + if (shortcut) { + logShortcut(instance, shortcut, "error", "failed", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}`, + `category=${shortcutErrorCategory(error)}` + ]); + } else { + send({ + cmd: "logMessage", + uuid: instance.uuid, + actionid: instance.actionid, + key: instance.key, + level: "error", + message: `[Codex Micro] action failed category=${shortcutErrorCategory(error)}` + }); + } send({ cmd: "showAlert", uuid: instance.uuid, actionid: instance.actionid, key: instance.key }); + } finally { + if (shortcut && pressed) collectBridgeDiagnostics(instance, shortcut, traceId); } } @@ -546,7 +757,9 @@ function connect() { socket.on("open", () => { send({ code: 0, cmd: "connected", uuid: PLUGIN_UUID }); if (process.env.CODEX_BRIDGE_AUTOSTART !== "0") { - void bridgeSetup.ensureService().catch(() => {}); + void ensureBundledBridgeCurrent().catch(error => { + console.error(`[Codex Micro] Automatic Bridge reconciliation failed: ${error.message}`); + }); } clearInterval(pollTimer); pollTimer = setInterval(() => void pollBridge(), 500); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js index 0770e9a..6290122 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js @@ -6,14 +6,16 @@ import { mkdir, readFile, realpath, + rename, rm, + stat, writeFile } from "node:fs/promises"; import { execFile, spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { promisify } from "node:util"; import { homedir } from "node:os"; -import { delimiter, isAbsolute, join, resolve } from "node:path"; +import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; import { discoverDebugPort, focusCodex, @@ -48,6 +50,70 @@ async function readJson(path) { } } +async function fileSha256(path) { + try { + return createHash("sha256").update(await readFile(path)).digest("hex"); + } catch { + return null; + } +} + +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +export async function acquireFilesystemLock({ + lockPath, + timeoutMs = 15000, + staleMs = 300000, + wait = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)) +}) { + const ownerToken = randomBytes(24).toString("base64url"); + await mkdir(dirname(lockPath), { recursive: true }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await mkdir(lockPath, { mode: 0o700 }); + await writeFile(join(lockPath, "owner.json"), `${JSON.stringify({ + pid: process.pid, + ownerToken, + createdAt: new Date().toISOString() + })}\n`); + return async () => { + const owner = await readJson(join(lockPath, "owner.json")); + if (owner?.ownerToken === ownerToken) { + await rm(lockPath, { recursive: true, force: true }); + } + }; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + const [lockInfo, owner] = await Promise.all([ + stat(lockPath).catch(() => null), + readJson(join(lockPath, "owner.json")) + ]); + if (lockInfo && Date.now() - lockInfo.mtimeMs > staleMs && !processIsAlive(Number(owner?.pid))) { + const abandonedLock = `${lockPath}.abandoned-${process.pid}-${ownerToken}`; + try { + await rename(lockPath, abandonedLock); + await rm(abandonedLock, { recursive: true, force: true }); + } catch (recoveryError) { + if (!await exists(lockPath)) continue; + if (!["ENOENT", "EEXIST", "EPERM", "EACCES"].includes(recoveryError?.code)) throw recoveryError; + } + continue; + } + await wait(100); + } + } + throw new Error("Another lifecycle operation is still running."); +} + async function nodeVersion(executable, execute) { try { const { stdout = "" } = await execute(executable, ["--version"]); @@ -142,15 +208,21 @@ export function createBridgeInstaller({ codexChannel = process.env.CODEX_DESKTOP_CHANNEL || "stable", execute = execFileAsync, spawnProcess = spawn, - fetchImpl = fetch + fetchImpl = fetch, + serviceStartTimeoutMs = 8000, + wait = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)) }) { const appRoot = bridgeDataRoot({ platform, home, localAppData }); const bridgeRuntime = join(appRoot, "bridge.mjs"); const tokenPath = join(appRoot, "bridge-token"); + const pidPath = join(appRoot, "bridge.pid"); const installMetadata = join(appRoot, "install.json"); + const lifecycleLock = join(dirname(appRoot), ".OpenCodexMicro.lifecycle.lock"); const installerRoot = resolve(pluginRoot, "installer"); const bundledRuntime = join(installerRoot, "bridge.mjs"); const bundledIcon = join(installerRoot, "CodexBridge.png"); + const runtimeBackup = join(appRoot, ".bridge.mjs.previous"); + const metadataBackup = join(appRoot, ".install.json.previous"); const userApplications = join(home, "Applications"); const bridgeApp = join(userApplications, "Codex Bridge.app"); @@ -166,6 +238,7 @@ export function createBridgeInstaller({ let authorizationCache = null; let authorizationPromise = null; let authorizationGeneration = 0; + let lifecycleOperation = null; function resetAuthorizationCache() { authorizationGeneration += 1; @@ -203,33 +276,65 @@ export function createBridgeInstaller({ }); const payload = await response.json(); if (!response.ok || payload.ok === false) throw new Error(payload.error || `Bridge HTTP ${response.status}`); - return { serviceOnline: true, cdpConnected: Boolean(payload.codexConnected), serviceError: null }; + return { + serviceOnline: true, + cdpConnected: Boolean(payload.codexConnected), + serviceVersion: typeof payload.bridgeVersion === "string" ? payload.bridgeVersion : null, + serviceRuntimeHash: typeof payload.runtimeHash === "string" ? payload.runtimeHash : null, + serviceError: null + }; } catch (error) { - return { serviceOnline: false, cdpConnected: false, serviceError: error.message }; + return { + serviceOnline: false, + cdpConnected: false, + serviceVersion: null, + serviceRuntimeHash: null, + serviceError: error.message + }; } } async function status() { - const [runtimeInstalled, tokenInstalled, metadata, probe] = await Promise.all([ + const [runtimeInstalled, tokenInstalled, metadata, probe, bundledRuntimeHash, installedRuntimeHash] = await Promise.all([ exists(bridgeRuntime), exists(tokenPath), readJson(installMetadata), - probeBridge() + probeBridge(), + fileSha256(bundledRuntime), + fileSha256(bridgeRuntime) ]); const macAppInstalled = platform === "darwin" ? await exists(bridgeExecutable, fsConstants.X_OK) : runtimeInstalled; const agentInstalled = platform === "darwin" ? await exists(bridgeAgent) : true; const installed = macAppInstalled && runtimeInstalled && tokenInstalled && agentInstalled; + const installationDetected = Boolean( + runtimeInstalled || tokenInstalled || metadata || probe.serviceOnline || + (platform === "darwin" && (macAppInstalled || agentInstalled)) + ); + const metadataMatchesBundle = Boolean( + bundledRuntimeHash && + metadata?.version === version && + metadata?.runtimeHash === bundledRuntimeHash && + installedRuntimeHash === bundledRuntimeHash + ); + const runningMatchesBundle = !probe.serviceOnline || Boolean( + bundledRuntimeHash && + probe.serviceVersion === version && + probe.serviceRuntimeHash === bundledRuntimeHash + ); return { supported: platform === "win32" || (platform === "darwin" && Number.isInteger(uid)), platform, installed, + installationDetected, appInstalled: macAppInstalled, serviceInstalled: runtimeInstalled && tokenInstalled && agentInstalled, installedVersion: metadata?.version || null, bundledVersion: version, - needsUpdate: !installed || metadata?.version !== version, + bundledRuntimeHash, + installedRuntimeHash, + needsUpdate: !installed || !metadataMatchesBundle || !runningMatchesBundle, appPath: platform === "darwin" ? bridgeApp : appRoot, nodeExecutable: metadata?.nodeExecutable || null, nodeVersion: metadata?.nodeVersion || null, @@ -259,7 +364,7 @@ export function createBridgeInstaller({ } } - async function installMac(nodeRuntime) { + async function installMac(nodeRuntime, appVersion = version) { await mkdir(userApplications, { recursive: true }); await mkdir(agentsRoot, { recursive: true }); await rm(bridgeApp, { recursive: true, force: true }); @@ -278,8 +383,8 @@ export function createBridgeInstaller({ CFBundleIdentifierio.opencodexmicro.bridge CFBundleNameCodex Bridge CFBundlePackageTypeAPPL - CFBundleShortVersionString${xml(version)} - CFBundleVersion${xml(version)} + CFBundleShortVersionString${xml(appVersion)} + CFBundleVersion${xml(appVersion)} LSMinimumSystemVersion13.0 LSUIElement @@ -304,17 +409,211 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi Labelio.opencodexmicro.bridge ProgramArguments${xml(nodeRuntime.executable)}${xml(bridgeRuntime)} + EnvironmentVariables + CODEX_BRIDGE_DATA_ROOT${xml(appRoot)} + RunAtLoadKeepAlive ProcessTypeBackgroundThrottleInterval2 StandardOutPath${xml(join(appRoot, "bridge.log"))} StandardErrorPath${xml(join(appRoot, "bridge-error.log"))} `, { mode: 0o644 }); - try { await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); } catch {} await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); } - async function install() { + async function acquireLifecycleLock() { + await mkdir(appRoot, { recursive: true, mode: 0o700 }); + return acquireFilesystemLock({ lockPath: lifecycleLock, wait }); + } + + function serializeLifecycle(operation) { + if (lifecycleOperation) return lifecycleOperation; + const running = (async () => { + const release = await acquireLifecycleLock(); + try { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await rename(runtimeBackup, bridgeRuntime); + } + if (!await exists(installMetadata) && await exists(metadataBackup)) { + await rename(metadataBackup, installMetadata); + } + return await operation(); + } finally { + await release(); + } + })(); + lifecycleOperation = running; + return running.finally(() => { + if (lifecycleOperation === running) lifecycleOperation = null; + }); + } + + async function stopWindowsService() { + const recordedPid = Number((await readFile(pidPath, "utf8").catch(() => "")).trim()); + const stopScript = "$target=$env:CODEX_BRIDGE_TARGET_RUNTIME; $targetPid=0; [void][int]::TryParse($env:CODEX_BRIDGE_TARGET_PID, [ref]$targetPid); $candidates=@(Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.Name -like 'node*' -and $_.CommandLine -and ($_.CommandLine.TrimEnd().EndsWith($target, [System.StringComparison]::OrdinalIgnoreCase) -or $_.CommandLine.TrimEnd().EndsWith(('\"' + $target + '\"'), [System.StringComparison]::OrdinalIgnoreCase)) }); $processes=if ($targetPid -gt 0) { @($candidates | Where-Object { $_.ProcessId -eq $targetPid }) } else { $candidates }; $processes | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $processes | ForEach-Object { try { Wait-Process -Id $_.ProcessId -Timeout 5 -ErrorAction Stop } catch {} }"; + await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", stopScript], { + timeout: 7000, + windowsHide: true, + env: { + ...process.env, + CODEX_BRIDGE_TARGET_RUNTIME: bridgeRuntime, + CODEX_BRIDGE_TARGET_PID: Number.isInteger(recordedPid) && recordedPid > 0 ? String(recordedPid) : "" + } + }); + await rm(pidPath, { force: true }); + lastWindowsServiceStart = 0; + } + + async function waitForServiceOffline() { + const deadline = Date.now() + Math.min(serviceStartTimeoutMs, 5000); + do { + if (!(await probeBridge()).serviceOnline) return; + await wait(100); + } while (Date.now() < deadline); + throw new Error("The managed Codex Bridge process did not stop before replacement."); + } + + async function stopService() { + if (platform === "darwin") { + const wasOnline = (await probeBridge()).serviceOnline; + if (await exists(bridgeAgent)) { + try { + await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); + } catch (error) { + if (wasOnline) throw new Error(`Codex Bridge LaunchAgent could not be stopped: ${error.message}`); + } + } + if (wasOnline) await waitForServiceOffline(); + return; + } + if (platform === "win32") await stopWindowsService(); + } + + async function commitRuntime(stagedRuntime) { + await rm(runtimeBackup, { force: true }); + if (await exists(bridgeRuntime)) await rename(bridgeRuntime, runtimeBackup); + try { + await rename(stagedRuntime, bridgeRuntime); + } catch (error) { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await rename(runtimeBackup, bridgeRuntime); + } + throw error; + } + } + + async function restorePreviousRuntime(previousRuntime) { + await rm(bridgeRuntime, { force: true }); + if (await exists(runtimeBackup)) { + await rename(runtimeBackup, bridgeRuntime); + return; + } + if (previousRuntime) { + const rollbackRuntime = join(appRoot, `.bridge.mjs.rollback-${process.pid}`); + await writeFile(rollbackRuntime, previousRuntime); + await rename(rollbackRuntime, bridgeRuntime); + } + } + + async function commitMetadata(metadataText) { + const stagedMetadata = join(appRoot, `.install.json.installing-${process.pid}`); + await writeFile(stagedMetadata, metadataText, { mode: 0o600 }); + await rm(metadataBackup, { force: true }); + if (await exists(installMetadata)) await rename(installMetadata, metadataBackup); + try { + await rename(stagedMetadata, installMetadata); + } catch (error) { + if (!await exists(installMetadata) && await exists(metadataBackup)) { + await rename(metadataBackup, installMetadata); + } + throw error; + } + } + + async function restorePreviousMetadata(previousMetadataText, useBackup = true) { + await rm(installMetadata, { force: true }); + if (useBackup && await exists(metadataBackup)) { + await rename(metadataBackup, installMetadata); + return; + } + await rm(metadataBackup, { force: true }); + if (previousMetadataText) { + await writeFile(installMetadata, previousMetadataText, { mode: 0o600 }); + } + } + + async function discardTransactionBackups() { + await Promise.all([ + rm(runtimeBackup, { force: true }), + rm(metadataBackup, { force: true }) + ]); + } + + async function startInstalledService(metadata) { + if (!metadata?.nodeExecutable) { + throw new Error("Codex Bridge runtime metadata is missing its Node.js executable."); + } + if (platform === "darwin") { + await installMac({ + executable: metadata.nodeExecutable, + version: metadata.nodeVersion || "unknown", + source: metadata.nodeSource || "unknown" + }, metadata.version || "0.0.0"); + } + if (platform === "win32") await ensureServiceUnlocked(); + } + + async function waitForAnyService() { + const deadline = Date.now() + serviceStartTimeoutMs; + do { + const probe = await probeBridge(); + if (probe.serviceOnline) return probe; + await wait(100); + } while (Date.now() < deadline); + throw new Error("The restored Codex Bridge service did not become reachable."); + } + + async function rollbackInterruptedUpdate() { + const hasRuntimeBackup = await exists(runtimeBackup); + const hasMetadataBackup = await exists(metadataBackup); + if (!hasRuntimeBackup && !hasMetadataBackup) return false; + await stopService(); + if (hasRuntimeBackup) { + await rm(bridgeRuntime, { force: true }); + await rename(runtimeBackup, bridgeRuntime); + } + if (hasMetadataBackup) { + await rm(installMetadata, { force: true }); + await rename(metadataBackup, installMetadata); + } + resetAuthorizationCache(); + const restoredMetadata = await readJson(installMetadata); + await startInstalledService(restoredMetadata); + await waitForAnyService(); + return true; + } + + async function waitForExpectedService(expectedRuntimeHash, expectedVersion = version) { + const deadline = Date.now() + serviceStartTimeoutMs; + let lastProbe = null; + do { + lastProbe = await probeBridge(); + if ( + lastProbe.serviceOnline && + lastProbe.serviceVersion === expectedVersion && + lastProbe.serviceRuntimeHash === expectedRuntimeHash + ) { + return lastProbe; + } + await wait(100); + } while (Date.now() < deadline); + const detail = lastProbe?.serviceOnline + ? "the running process reported a different build" + : "the restarted service did not become reachable"; + throw new Error(`Codex Bridge ${expectedVersion} restart failed: ${detail}.`); + } + + async function installUnlocked() { if (!(platform === "win32" || (platform === "darwin" && Number.isInteger(uid)))) { throw new Error("Codex Bridge installation is supported on Windows and macOS only."); } @@ -328,31 +627,104 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi platform, execute }); + const previousStatus = await status(); + const previousRuntime = await readFile(bridgeRuntime).catch(() => null); + const previousRuntimeHash = previousRuntime + ? createHash("sha256").update(previousRuntime).digest("hex") + : null; + const previousMetadataText = await readFile(installMetadata, "utf8").catch(() => null); + const previousMetadata = await readJson(installMetadata); + const stagedRuntime = join(appRoot, `.bridge.mjs.installing-${process.pid}`); await mkdir(appRoot, { recursive: true, mode: 0o700 }); if (platform !== "win32") await chmod(appRoot, 0o700); - await copyFile(bundledRuntime, bridgeRuntime); - for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { - await copyFile(join(installerRoot, notice), join(appRoot, notice)); - } - if (!await exists(tokenPath)) { - await writeFile(tokenPath, `${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 }); + await rm(stagedRuntime, { force: true }); + await copyFile(bundledRuntime, stagedRuntime); + const runtimeHash = await fileSha256(stagedRuntime); + if (!runtimeHash) throw new Error("The bundled Codex Bridge runtime could not be verified."); + + console.log(`[Codex Bridge] ${previousStatus.installed ? "Updating" : "Installing"} ${version}; stopping only the managed Bridge process.`); + try { + await stopService(); + await commitRuntime(stagedRuntime); + for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { + await copyFile(join(installerRoot, notice), join(appRoot, notice)); + } + if (!await exists(tokenPath)) { + await writeFile(tokenPath, `${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 }); + } + resetAuthorizationCache(); + await commitMetadata(`${JSON.stringify({ + version, + runtimeHash, + platform, + codexChannel, + nodeExecutable: nodeRuntime.executable, + nodeVersion: nodeRuntime.version, + nodeSource: nodeRuntime.source, + installedAt: new Date().toISOString() + }, null, 2)}\n`); + if (platform === "darwin") await installMac(nodeRuntime); + if (platform === "win32") await ensureServiceUnlocked(); + await waitForExpectedService(runtimeHash); + const installed = await status(); + if (installed.needsUpdate) throw new Error("The restarted Codex Bridge did not match the bundled runtime."); + await discardTransactionBackups(); + console.log(`[Codex Bridge] ${version} is installed and the restarted process reported the expected build.`); + return installed; + } catch (error) { + console.error(`[Codex Bridge] ${version} update failed; restoring the previous managed runtime.`); + let rollbackError = null; + try { + await stopService(); + if (previousRuntime) { + await restorePreviousRuntime(previousRuntime); + const recoveryVersion = previousMetadata?.version || previousStatus.serviceVersion || "0.0.0"; + const recoveryMetadataText = previousMetadata ? previousMetadataText : `${JSON.stringify({ + version: recoveryVersion, + runtimeHash: previousRuntimeHash, + platform, + codexChannel: previousStatus.codexChannel || codexChannel, + nodeExecutable: nodeRuntime.executable, + nodeVersion: nodeRuntime.version, + nodeSource: nodeRuntime.source, + installedAt: new Date().toISOString(), + recovered: true + }, null, 2)}\n`; + await restorePreviousMetadata(recoveryMetadataText, Boolean(previousMetadata)); + resetAuthorizationCache(); + const restoredMetadata = await readJson(installMetadata); + if (restoredMetadata?.nodeExecutable) { + const previousNodeRuntime = { + executable: restoredMetadata.nodeExecutable, + version: restoredMetadata.nodeVersion || "unknown", + source: restoredMetadata.nodeSource || "unknown" + }; + if (platform === "darwin") await installMac(previousNodeRuntime, restoredMetadata.version || "0.0.0"); + if (platform === "win32") await ensureServiceUnlocked(); + } + } else { + if (platform === "darwin") { + await rm(bridgeAgent, { force: true }); + await rm(bridgeApp, { recursive: true, force: true }); + } + await rm(appRoot, { recursive: true, force: true }); + resetAuthorizationCache(); + } + } catch (rollbackFailure) { + rollbackError = rollbackFailure; + } + const rollbackDetail = rollbackError ? ` Rollback also failed: ${rollbackError.message}` : " Previous runtime restored."; + throw new Error(`Codex Bridge installation failed: ${error.message}${rollbackDetail}`); + } finally { + await rm(stagedRuntime, { force: true }); } - resetAuthorizationCache(); - await writeFile(installMetadata, `${JSON.stringify({ - version, - platform, - codexChannel, - nodeExecutable: nodeRuntime.executable, - nodeVersion: nodeRuntime.version, - nodeSource: nodeRuntime.source, - installedAt: new Date().toISOString() - }, null, 2)}\n`, { mode: 0o600 }); - if (platform === "darwin") await installMac(nodeRuntime); - if (platform === "win32") await ensureService(); - return status(); } - async function ensureService() { + async function install() { + return serializeLifecycle(installUnlocked); + } + + async function ensureServiceUnlocked() { if (platform !== "win32") return status(); const probe = await probeBridge(); if (probe.serviceOnline || Date.now() - lastWindowsServiceStart < 2000) return { ...await status(), ...probe }; @@ -371,13 +743,56 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi CODEX_BRIDGE_TOKEN: token } }); + if (Number.isInteger(child.pid) && child.pid > 0) { + await writeFile(pidPath, `${child.pid}\n`, { mode: 0o600 }); + } else { + await rm(pidPath, { force: true }); + } child.unref?.(); lastWindowsServiceStart = Date.now(); return status(); } + async function ensureService() { + return serializeLifecycle(ensureServiceUnlocked); + } + + async function ensureCurrent() { + return serializeLifecycle(async () => { + const current = await status(); + if (!current.installationDetected) return current; + const interruptedUpdate = await exists(runtimeBackup) || await exists(metadataBackup); + if (interruptedUpdate && current.needsUpdate) { + await rollbackInterruptedUpdate(); + console.log("[Codex Bridge] Interrupted update rolled back; retrying the bundled update in the same lifecycle operation."); + return installUnlocked(); + } + if (!current.installed || current.needsUpdate) { + console.log(`[Codex Bridge] Installed or running build differs from bundled ${version}; starting automatic update.`); + return installUnlocked(); + } + if (!current.serviceOnline) { + try { + await startInstalledService(await readJson(installMetadata)); + await waitForExpectedService(current.bundledRuntimeHash); + const ready = await status(); + await discardTransactionBackups(); + return ready; + } catch (error) { + if (interruptedUpdate && await rollbackInterruptedUpdate()) { + console.log("[Codex Bridge] Interrupted restart rolled back; retrying the bundled update once."); + return installUnlocked(); + } + throw error; + } + } + await discardTransactionBackups(); + return current; + }); + } + async function launch() { - const current = await status(); + const current = await ensureCurrent(); if (!current.installed) throw new Error("Codex Bridge is not installed. Use Install / Repair first."); if (platform === "darwin") { await execute("/usr/bin/open", [bridgeApp]); @@ -397,28 +812,25 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi throw new Error(`Codex Bridge launch is not supported on ${platform}.`); } - async function uninstall() { + async function uninstallUnlocked() { if (!(platform === "win32" || (platform === "darwin" && Number.isInteger(uid)))) { throw new Error("Codex Bridge uninstallation is supported on Windows and macOS only."); } if (platform === "darwin") { - try { await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); } catch {} + await stopService(); await rm(bridgeAgent, { force: true }); await rm(bridgeApp, { recursive: true, force: true }); } else { - const stopScript = "$target=$env:CODEX_BRIDGE_TARGET_RUNTIME; Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.Name -like 'node*' -and $_.CommandLine -like ('*' + $target + '*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"; - try { - await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", stopScript], { - timeout: 5000, - windowsHide: true, - env: { ...process.env, CODEX_BRIDGE_TARGET_RUNTIME: bridgeRuntime } - }); - } catch {} + try { await stopService(); } catch {} } await rm(appRoot, { recursive: true, force: true }); resetAuthorizationCache(); return status(); } - return { status, install, launch, uninstall, ensureService, authorizationHeaders }; + async function uninstall() { + return serializeLifecycle(uninstallUnlocked); + } + + return { status, install, launch, uninstall, ensureService, ensureCurrent, authorizationHeaders }; } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs index 7fd3539..c0d48c3 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs @@ -1,23 +1,36 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { WebSocketServer } from "ws"; const packageRootUrl = new URL("..", import.meta.url); const manifest = JSON.parse(await readFile(new URL("manifest.json", packageRootUrl))); +const pluginBundleSource = await readFile(new URL("dist/app.js", packageRootUrl), "utf8"); +const bundledBridgeSource = await readFile(new URL("installer/bridge.mjs", packageRootUrl)); +const bundledBridgeHash = createHash("sha256") + .update(bundledBridgeSource) + .digest("hex"); const navigateAction = manifest.Actions.find(action => action.UUID === "com.ulanzi.ulanzistudio.codexmicro.navigate" ); assert.deepEqual(navigateAction?.Controllers, ["Encoder"]); assert.equal(navigateAction?.Encoder?.layout, "$UA1"); -assert.equal(manifest.Version, "0.6.0"); +assert.equal(navigateAction?.Icon, "assets/icons/task-idle.svg"); +assert.equal(navigateAction?.States?.[0]?.Image, "assets/icons/task-idle.svg"); +assert.equal(manifest.Version, "0.6.1"); +assert.match(String(bundledBridgeSource), /BRIDGE_VERSION = .*"0\.6\.1"/); +assert.doesNotMatch(String(bundledBridgeSource), /process\.env\.CODEX_BRIDGE_VERSION/); +assert.match(pluginBundleSource, /scheduleBridgeReconcileRetry\(\);/); +assert.match(pluginBundleSource, /bridgeReconcilePromise === running\) bridgeReconcilePromise = null/); assert.equal(manifest.Software?.MinVersion, "3.0.1"); assert.equal(manifest.OS?.find(item => item.Platform === "mac")?.MinimumVersion, "13.0"); assert.equal(manifest.OS?.find(item => item.Platform === "windows")?.MinimumVersion, "10.0"); -const completeGif = await readFile(new URL("assets/icons/task-complete.gif", packageRootUrl)); -assert.equal(completeGif.subarray(0, 6).toString("ascii"), "GIF89a"); -assert.ok(completeGif.length > 0, "animated complete task icon must be packaged"); +const taskActions = manifest.Actions.filter(action => /\.task[1-5]$/.test(action.UUID)); +assert.ok(taskActions.every(action => action.Icon === "assets/icons/task-idle.svg")); +assert.ok(taskActions.every(action => action.States?.[0]?.Image === "assets/icons/task-idle.svg")); +assert.ok((await readFile(new URL("assets/icons/task-idle.svg", packageRootUrl))).length > 0); assert.ok( manifest.Actions.every(action => action.PropertyInspectorPath === "property-inspector/setup.html"), "every action must expose the shared Bridge setup inspector" @@ -127,18 +140,29 @@ for (const locale of [ } const bridgeRequests = []; +const bridgeTraceRoutes = new Map(); const bridge = createServer((request, response) => { bridgeRequests.push(`${request.method} ${request.url}`); + const traceId = String(request.headers["x-codex-trace-id"] || ""); + if (traceId && request.url !== "/diagnostics/trace") { + bridgeTraceRoutes.set(traceId, request.url?.startsWith("/thread/") ? "thread-click" : "action"); + } response.setHeader("Content-Type", "application/json"); if (request.url === "/health") { - response.end(JSON.stringify({ ok: true, codexConnected: true, updatedAt: Date.now() })); + response.end(JSON.stringify({ + ok: true, + bridgeVersion: manifest.Version, + runtimeHash: bundledBridgeHash, + codexConnected: true, + updatedAt: Date.now() + })); return; } if (request.url === "/state") { response.end(JSON.stringify({ connected: true, slots: [ - { id: 0, threadKey: "11111111-1111-1111-1111-111111111111", title: "排查 PowerShell 配置与完整路径问题", status: "thinking" }, + { id: 0, threadKey: "11111111-1111-1111-1111-111111111111", title: "排查 PowerShell 配置与完整路径和多窗口激活问题", status: "thinking" }, { id: 1, threadKey: "22222222-2222-2222-2222-222222222222", title: "A&B \"D\" 'E'", status: "unread" }, { id: 2, threadKey: "33333333-3333-3333-3333-333333333333", title: "Input task", status: "input" }, { id: 3, threadKey: "44444444-4444-4444-4444-444444444444", title: "Failed task", status: "error" }, @@ -148,6 +172,35 @@ const bridge = createServer((request, response) => { })); return; } + if (request.url === "/diagnostics/trace") { + const route = bridgeTraceRoutes.get(traceId); + response.end(JSON.stringify({ + ok: true, + diagnostics: { + traceId, + complete: true, + events: route === "thread-click" + ? [ + { event: "server.request", offsetMs: 0, route, slot: 1 }, + { event: "cdp.stage", offsetMs: 12, stage: "focus.bring-to-front", outcome: "succeeded", durationMs: 4 }, + { event: "cdp.stage", offsetMs: 51, stage: "task.dom-activate", outcome: "succeeded", durationMs: 9, background: true } + ] + : [ + { event: "server.request", offsetMs: 0, route, action: "model-sol-high", phase: "down" }, + { event: "renderer.poll", offsetMs: 173, stage: "model.wait-main-menu", outcome: "succeeded", attempts: 4, durationMs: 173 } + ] + } + })); + return; + } + if (request.url === "/action/model-sol-medium/down") { + response.statusCode = 500; + response.end(JSON.stringify({ + ok: false, + error: "Authorization Bearer synthetic-secret; task title: private user text" + })); + return; + } response.end(JSON.stringify({ ok: true, bridge: true })); }); await new Promise(resolve => bridge.listen(0, "127.0.0.1", resolve)); @@ -169,6 +222,10 @@ const child = spawn(nodeBinary, ["dist/app.js", "127.0.0.1", String(hostPort), " }, stdio: ["ignore", "pipe", "pipe"] }); +const childStdout = []; +const childStderr = []; +child.stdout.on("data", chunk => childStdout.push(String(chunk))); +child.stderr.on("data", chunk => childStderr.push(String(chunk))); try { const client = await new Promise((resolve, reject) => { @@ -195,14 +252,16 @@ try { ); assert.equal(setupStatus?.payload?.status?.serviceOnline, true); assert.equal(setupStatus?.payload?.status?.cdpConnected, true); - assert.equal(setupStatus?.payload?.status?.bundledVersion, "0.6.0"); + assert.equal(setupStatus?.payload?.status?.bundledVersion, "0.6.1"); + assert.equal(setupStatus?.payload?.status?.serviceVersion, "0.6.1"); + assert.equal(setupStatus?.payload?.status?.serviceRuntimeHash, bundledBridgeHash); const taskColors = [ - "#2589f5", - "#28b875", - "#ed9f20", - "#e34d62", - "#667085" + ["#2589f5"], + ["#28b875", "#6ce9a6"], + ["#ed9f20"], + ["#e34d62"], + ["#98a2b3"] ]; for (let index = 0; index < taskColors.length; index += 1) { client.send(JSON.stringify({ @@ -213,50 +272,83 @@ try { param: {} })); } - await new Promise(resolve => setTimeout(resolve, 700)); + await new Promise(resolve => setTimeout(resolve, 1200)); const taskSvgs = []; + const taskItems = []; for (let index = 0; index < taskColors.length; index += 1) { const taskUuid = `com.ulanzi.ulanzistudio.codexmicro.task${index + 1}`; const state = messages.find(message => message.cmd === "state" && message.param?.statelist?.[0]?.uuid === taskUuid ); const item = state?.param?.statelist?.[0]; + taskItems.push(item); assert.equal(item?.type, 1); assert.match(item?.data || "", /^data:image\/svg\+xml;base64,/); - assert.equal(item?.showtext, false); - assert.equal(item?.textdata, ""); + assert.equal(item?.showtext, true); + assert.equal(item?.textData, item?.textdata); + assert.ok(item?.textData.length > 0); assert.equal(Object.hasOwn(item || {}, "path"), false); assert.equal(Object.hasOwn(item || {}, "gifpath"), false); assert.equal(Object.hasOwn(item || {}, "state"), false, "task icon update must not send a state index"); const svg = Buffer.from(item.data.split(",")[1], "base64").toString(); taskSvgs.push(svg); - assert.match(svg, new RegExp(taskColors[index])); + assert.ok( + taskColors[index].some(color => svg.includes(color)), + `task ${index + 1} must use its status palette` + ); assert.match(svg, /width="196" height="196"/); - assert.match(svg, /text-anchor="middle"/); + assert.match(svg, /data-role="title-surface"/); + assert.doesNotMatch(svg, /]*>(.*?)<\/tspan>/g), match => match[1]); - assert.equal(balancedLines.length, 2, "long mixed title must render on exactly two lines"); + const balancedLines = taskItems[0].textData.split("\n"); + assert.equal(balancedLines.length, 4, "long mixed title must render on exactly four lines"); assert.ok(balancedLines.every(line => line.length > 0)); const visibleUnits = line => Array.from(line).reduce((total, character) => total + (/[\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff]/u.test(character) ? 2 : character === " " ? 0.5 : 1), 0 ); - assert.ok( - Math.abs(visibleUnits(balancedLines[0]) - visibleUnits(balancedLines[1])) <= 3, - "mixed-language title lines must have similar visual widths" + assert.ok(balancedLines.every(line => visibleUnits(line) <= 10), "every title line must preserve horizontal margins"); + assert.match(balancedLines[3], /…$/, "truncated title must add an ellipsis to the fourth line"); + assert.equal(taskItems[1].textData, "A&B \"D\n\" 'E'"); + const completeStates = messages.filter(message => + message.cmd === "state" && + message.param?.statelist?.[0]?.uuid === "com.ulanzi.ulanzistudio.codexmicro.task2" ); - assert.match(balancedLines[1], /…$/, "truncated title must add an ellipsis to the second line"); - assert.match(taskSvgs[1], /A&B/); - assert.match(taskSvgs[1], /<C>/); - assert.match(taskSvgs[1], /"D"/); - assert.match(taskSvgs[1], /'E'/); - assert.doesNotMatch(taskSvgs[1], //); + const completeSvgs = completeStates.map(message => + Buffer.from(message.param.statelist[0].data.split(",")[1], "base64").toString() + ); + assert.ok(completeSvgs.some(svg => svg.includes("#28b875")), "complete state must include the base green frame"); + assert.ok(completeSvgs.some(svg => svg.includes("#6ce9a6")), "complete state must flash to the bright green frame"); - client.send(JSON.stringify({ cmd: "keydown", uuid: "com.ulanzi.ulanzistudio.codexmicro.task1", actionid: "a1", key: "0_0", param: {} })); - client.send(JSON.stringify({ cmd: "run", uuid: "com.ulanzi.ulanzistudio.codexmicro.task1", actionid: "a1", key: "0_0", param: {} })); - client.send(JSON.stringify({ cmd: "keyup", uuid: "com.ulanzi.ulanzistudio.codexmicro.task1", actionid: "a1", key: "0_0", param: {} })); + for (let index = 0; index < taskColors.length; index += 1) { + const event = { + uuid: `com.ulanzi.ulanzistudio.codexmicro.task${index + 1}`, + actionid: `a${index + 1}`, + key: `0_${index}`, + param: {} + }; + client.send(JSON.stringify({ cmd: "keydown", ...event })); + client.send(JSON.stringify({ cmd: "run", ...event })); + client.send(JSON.stringify({ cmd: "keyup", ...event })); + } await new Promise(resolve => setTimeout(resolve, 150)); - const threadRequests = bridgeRequests.filter(item => item.includes("/thread/11111111-1111-1111-1111-111111111111/click?slot=0")); - assert.equal(threadRequests.length, 1, "run must not duplicate a keydown task invocation"); + for (let index = 0; index < taskColors.length; index += 1) { + const digit = String(index + 1); + const threadKey = `${digit.repeat(8)}-${digit.repeat(4)}-${digit.repeat(4)}-${digit.repeat(4)}-${digit.repeat(12)}`; + const threadRequests = bridgeRequests.filter(item => item.includes(`/thread/${threadKey}/click?slot=${index}`)); + assert.equal(threadRequests.length, 1, `task ${index + 1} run must not duplicate its keydown invocation`); + const taskLogs = messages.filter(message => + message.cmd === "logMessage" && message.message?.includes(`name=task-${index + 1}`) + ); + assert.ok(taskLogs.every(message => + message.uuid === "com.ulanzi.ulanzistudio.codexmicro" && + message.actionid === "" && + message.key === "" + ), "Studio log messages must use the main-service identity"); + assert.ok(taskLogs.some(message => /event=received .*phase=down .*target=slot-[1-5]/.test(message.message))); + assert.ok(taskLogs.some(message => /event=dispatching .*phase=down .*transport=bridge-http/.test(message.message))); + assert.ok(taskLogs.some(message => /event=succeeded .*phase=down .*durationMs=\d+/.test(message.message))); + assert.ok(taskLogs.some(message => /event=ignored .*phase=up .*reason=keydown-only/.test(message.message))); + } const navigateEvent = { uuid: "com.ulanzi.ulanzistudio.codexmicro.navigate", @@ -285,11 +377,12 @@ try { ); const navigateItem = navigateState?.param?.statelist?.[0]; assert.equal(navigateItem?.type, 1); - assert.equal(navigateItem?.showtext, false); - assert.equal(navigateItem?.textdata, ""); + assert.equal(navigateItem?.showtext, true); + assert.equal(navigateItem?.textData, navigateItem?.textdata); + assert.match(navigateItem?.textData || "", /排查/); const navigateSvg = Buffer.from(navigateItem.data.split(",")[1], "base64").toString(); assert.match(navigateSvg, /#2589f5/); - assert.match(navigateSvg, /排查/); + assert.doesNotMatch(navigateSvg, / + message.cmd === "logMessage" && message.message?.includes(`name=${action}`) + ); + assert.ok(actionLogs.some(message => /event=received .*phase=down .*target=model-gpt-5\.6-(?:sol|luna) effort-(?:high|max|medium)/.test(message.message))); + assert.ok(actionLogs.some(message => /event=dispatching .*phase=down .*transport=bridge-http/.test(message.message))); + assert.ok(actionLogs.some(message => /phase=up/.test(message.message)), `${action} must log keyup`); + } + const failedPresetLog = messages.find(message => + message.cmd === "logMessage" && + message.level === "error" && + message.message?.includes("name=model-sol-medium") && + message.message?.includes("event=failed") + ); + assert.match(failedPresetLog?.message || "", /category=bridge-rejected/); + const serializedLogs = JSON.stringify(messages.filter(message => message.cmd === "logMessage")); + assert.match(serializedLogs, /bridgeEvent=cdp\.stage/); + assert.match(serializedLogs, /stage=focus\.bring-to-front/); + assert.match(serializedLogs, /stage=task\.dom-activate/); + assert.match(serializedLogs, /bridgeEvent=renderer\.poll/); + assert.match(serializedLogs, /stage=model\.wait-main-menu/); + assert.ok([...bridgeTraceRoutes.keys()].every(traceId => /^[a-f0-9-]{36}$/.test(traceId))); + assert.doesNotMatch(serializedLogs, /synthetic-secret|private user text|11111111-1111-1111-1111-111111111111/); + const serializedConsole = `${childStdout.join("")}\n${childStderr.join("")}`; + for (const shortcut of [ + "task-1", "task-2", "task-3", "task-4", "task-5", + "model-sol-high", "model-luna-max", "model-sol-medium" + ]) { + assert.match(serializedConsole, new RegExp(`shortcut event=.*name=${shortcut}`)); + } + assert.doesNotMatch(serializedConsole, /synthetic-secret|private user text|11111111-1111-1111-1111-111111111111/); assert.equal( bridgeRequests.filter(item => item === "POST /focus").length, 1, diff --git a/package-lock.json b/package-lock.json index e2b8a74..b82b9c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-codex-micro", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-codex-micro", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "devDependencies": { "esbuild": "^0.25.12", diff --git a/package.json b/package.json index eb6a983..f9f7dd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-codex-micro", - "version": "0.6.0", + "version": "0.6.1", "private": true, "type": "module", "description": "Control Codex Desktop from Ulanzi Studio through a local bridge", diff --git a/scripts/build-bridge.mjs b/scripts/build-bridge.mjs index d4a1304..2ad2a64 100644 --- a/scripts/build-bridge.mjs +++ b/scripts/build-bridge.mjs @@ -1,9 +1,10 @@ import { build } from "esbuild"; -import { mkdir } from "node:fs/promises"; +import { mkdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; const root = resolve("."); const output = resolve(root, "dist", "bridge.mjs"); +const packageMetadata = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); await mkdir(resolve(root, "dist"), { recursive: true }); await build({ entryPoints: [resolve(root, "src", "bridge", "server.mjs")], @@ -13,6 +14,9 @@ await build({ format: "esm", target: "node20", minify: false, + define: { + __CODEX_BRIDGE_VERSION__: JSON.stringify(String(packageMetadata.version)) + }, banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" } diff --git a/scripts/install-plugin.mjs b/scripts/install-plugin.mjs index 4e0e3e0..aa21fa7 100644 --- a/scripts/install-plugin.mjs +++ b/scripts/install-plugin.mjs @@ -8,6 +8,10 @@ import { } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; +import { + acquireFilesystemLock, + createBridgeInstaller +} from "../integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js"; const source = resolve( "integration/com.ulanzi.codexmicro.ulanziPlugin" @@ -80,6 +84,10 @@ for (const locale of localizationFiles) { } await mkdir(pluginsRoot, { recursive: true }); +const releaseInstallLock = await acquireFilesystemLock({ + lockPath: join(pluginsRoot, `.${pluginName}.install.lock`) +}); +try { await rm(staging, { recursive: true, force: true }); await rm(backup, { recursive: true, force: true }); await mkdir(staging, { recursive: true }); @@ -101,16 +109,38 @@ for (const relative of [ const hadPreviousInstall = await exists(destination); if (hadPreviousInstall) await rename(destination, backup); +let destinationReplaced = false; try { await rename(staging, destination); + destinationReplaced = true; + const bridgeSetup = createBridgeInstaller({ + pluginRoot: destination, + bridgeUrl: process.env.CODEX_BRIDGE_URL || "http://127.0.0.1:17373", + version: String(manifest.Version) + }); + const before = await bridgeSetup.status(); + if (before.installationDetected) { + if (before.needsUpdate) { + console.log(`Installed Bridge ${before.installedVersion || "unknown"} differs from plugin ${before.bundledVersion}; updating and restarting it.`); + } + const after = await bridgeSetup.ensureCurrent(); + if (after.needsUpdate) throw new Error("Codex Bridge did not report the bundled runtime after restart."); + console.log(`Codex Bridge ${after.installedVersion} is current and online: ${after.serviceOnline}.`); + } else { + console.log("Codex Bridge is not installed; use any Action setup page for the initial installation."); + } } catch (error) { await rm(staging, { recursive: true, force: true }); + if (destinationReplaced) await rm(destination, { recursive: true, force: true }); if (hadPreviousInstall && await exists(backup)) { await rename(backup, destination); } throw error; } await rm(backup, { recursive: true, force: true }); +} finally { + await releaseInstallLock(); +} console.log(`Ulanzi Studio plugin installed at: ${destination}`); console.log("Restart Ulanzi Studio to load the updated Codex Micro plugin."); diff --git a/src/bridge/codex-cdp.mjs b/src/bridge/codex-cdp.mjs index e9d7cb2..b0d770d 100644 --- a/src/bridge/codex-cdp.mjs +++ b/src/bridge/codex-cdp.mjs @@ -356,6 +356,40 @@ function selectMainTarget(targets) { }) ?? pages.find((target) => !/avatar-overlay|composition-surface/i.test(target.url || "")); } +function traceErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timed out") || message.includes("timeout")) return "timeout"; + if (message.includes("disconnected") || message.includes("not running")) return "cdp-unavailable"; + if (message.includes("window")) return "focus-failed"; + if (message.includes("menu") || message.includes("model") || message.includes("reasoning")) return "renderer-state"; + return "cdp-operation"; +} + +async function runTraceStage(trace, stage, operation, fields = {}) { + const startedAt = performance.now(); + trace?.record("cdp.stage", { stage, outcome: "started", ...fields }); + try { + const result = await operation(); + trace?.record("cdp.stage", { + stage, + outcome: "succeeded", + durationMs: Math.round(performance.now() - startedAt), + ...fields + }); + return result; + } catch (error) { + trace?.record("cdp.stage", { + stage, + outcome: "failed", + category: traceErrorCategory(error), + durationMs: Math.round(performance.now() - startedAt), + ...fields + }); + throw error; + } +} + export class CodexCdpClient { socket = null; connectPromise = null; @@ -438,6 +472,25 @@ export class CodexCdpClient { } } + async focusWindow(trace = null) { + await runTraceStage(trace, "focus.connect", () => this.connect(), { + connection: this.socket?.readyState === WebSocket.OPEN ? "reused" : "open" + }); + const { windowId } = await runTraceStage( + trace, + "focus.get-window", + () => this.sendCommand("Browser.getWindowForTarget", {}) + ); + if (!Number.isInteger(windowId)) { + throw new Error("Codex Desktop window handle was not available"); + } + await runTraceStage(trace, "focus.maximize", () => this.sendCommand("Browser.setWindowBounds", { + windowId, + bounds: { windowState: "maximized" } + })); + await runTraceStage(trace, "focus.bring-to-front", () => this.sendCommand("Page.bringToFront", {})); + } + async clickAgent(slot) { await this.connect(); const snapshot = this.lastSnapshot ?? await this.snapshot(); @@ -446,23 +499,58 @@ export class CodexCdpClient { return this.clickThreadKey(agent.threadKey, slot); } - async clickThread(threadId, slot = 0) { - await this.connect(); - return this.clickThreadKey(localThreadKey(threadId), slot); + async clickThread(threadId, slot = 0, trace = null) { + await runTraceStage(trace, "task.connect", () => this.connect(), { slot: slot + 1 }); + return this.clickThreadKey(localThreadKey(threadId), slot, trace); } - async clickThreadKey(threadKey, slot) { + async clickThreadKey(threadKey, slot, trace = null) { // Use the same native HID path as Codex Micro. The DOM click introduced // perceptible navigation scheduling; it is now only a non-blocking fallback. try { - await this.dispatchAgent(slot, threadKey, 1); + await runTraceStage( + trace, + "task.native-act1", + () => this.dispatchAgent(slot, threadKey, 1), + { slot: slot + 1 } + ); + const finishBackground = trace?.defer?.(); + trace?.record("task.background", { stage: "scheduled", background: true, slot: slot + 1 }); void (async () => { await new Promise((resolve) => setTimeout(resolve, 35)); - await this.dispatchAgent(slot, threadKey, 0); - await this.activateThread(threadKey); - })().catch(() => {}); - } catch { - await this.activateThread(threadKey); + await runTraceStage( + trace, + "task.native-act0", + () => this.dispatchAgent(slot, threadKey, 0), + { background: true, slot: slot + 1 } + ); + await runTraceStage( + trace, + "task.dom-activate", + () => this.activateThread(threadKey), + { background: true, slot: slot + 1 } + ); + })().catch((error) => { + trace?.record("task.background", { + stage: "complete", + background: true, + outcome: "failed", + category: traceErrorCategory(error), + slot: slot + 1 + }); + }).finally(() => finishBackground?.()); + } catch (error) { + trace?.record("task.fallback", { + outcome: "started", + category: traceErrorCategory(error), + slot: slot + 1 + }); + await runTraceStage( + trace, + "task.dom-activate-fallback", + () => this.activateThread(threadKey), + { slot: slot + 1 } + ); } } @@ -480,20 +568,20 @@ export class CodexCdpClient { }, "codex-micro-hid-event"); } - async dispatchNamedAction(action, pressed) { + async dispatchNamedAction(action, pressed, trace = null) { const key = MICRO_ACTION_KEYS[action]; if (key) return this.dispatchAction(key, pressed ? 1 : 0); if (!RENDERER_ACTIONS.has(action)) { throw new Error(`Unsupported Codex bridge action: ${action}`); } if (!pressed) return true; - return this.dispatchRendererAction(action); + return this.dispatchRendererAction(action, trace); } - async dispatchRendererAction(action) { - await this.connect(); + async dispatchRendererAction(action, trace = null) { + await runTraceStage(trace, "model.connect", () => this.connect(), { action }); if (MODEL_PRESETS[action]) { - await this.dispatchModelPreset(action); + await this.dispatchModelPreset(action, trace); return true; } const invoked = await this.evaluate(rendererActionExpression(action)); @@ -501,13 +589,20 @@ export class CodexCdpClient { return true; } - async dispatchModelPreset(action) { + async dispatchModelPreset(action, trace = null) { const preset = MODEL_PRESETS[action]; if (!preset) throw new Error(`Unknown Codex model preset: ${action}`); + const presetStartedAt = performance.now(); + trace?.record("model.preset", { + action, + stage: "start", + outcome: "started", + targetEffort: preset.effort + }); const effortOrder = ["low", "medium", "high", "xhigh", "max"]; const targetEffortIndex = effortOrder.indexOf(preset.effort); const readState = async () => { - const state = await this.evaluate(`(() => { + const state = await runTraceStage(trace, "model.read-state", () => this.evaluate(`(() => { const visible = (element) => { const rect = element?.getBoundingClientRect?.(); return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); @@ -519,21 +614,30 @@ export class CodexCdpClient { effort: triggers[0].getAttribute("data-selected-reasoning-effort"), expanded: triggers[0].getAttribute("aria-expanded") === "true" }; - })()`); + })()`)); if (state?.error) throw new Error(state.error); + trace?.record("model.state", { + currentEffort: state.effort || "unknown", + effortMatched: state.effort === preset.effort, + modelMatched: state.text.includes(preset.displayName) + }); return state; }; const closeMenus = async () => { - for (let attempt = 0; attempt < 3 && (await readState()).expanded; attempt += 1) { - await this.pressRendererEscape(); + let attempts = 0; + for (; attempts < 3 && (await readState()).expanded; attempts += 1) { + await this.pressRendererEscape(trace, "model.close-menu"); } if ((await readState()).expanded) throw new Error("Codex intelligence menu did not close"); + trace?.record("model.menu-close", { attempts, outcome: "succeeded" }); }; const openMain = async () => { if (!(await readState()).expanded) { await this.clickRendererCandidates( '[...document.querySelectorAll("[data-codex-intelligence-trigger]")]', - "Codex intelligence trigger" + "Codex intelligence trigger", + trace, + "model.open-trigger" ); } await this.waitForRenderer(`(() => { @@ -547,8 +651,8 @@ export class CodexCdpClient { menu.querySelector("[data-reasoning-slider]") ) ).length === 1; - })()`, "Codex intelligence menu"); - const toggleState = await this.evaluate(`(() => { + })()`, "Codex intelligence menu", trace, "model.wait-main-menu"); + const toggleState = await runTraceStage(trace, "model.read-menu-shape", () => this.evaluate(`(() => { const menus = [...document.querySelectorAll('[role="menu"][data-state="open"]')].filter( (menu) => menu.querySelector("[data-model-picker-view-toggle]") || menu.querySelector("[data-reasoning-slider]") ); @@ -560,7 +664,11 @@ export class CodexCdpClient { expanded: toggles[0]?.getAttribute("aria-expanded") === "true", rowCount: menus[0]?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0 }; - })()`); + })()`)); + trace?.record("model.menu-shape", { + rowCount: toggleState.rowCount, + outcome: toggleState.expanded ? "expanded" : "collapsed" + }); if (toggleState.count === 0 && toggleState.rowCount === 2) return; if (toggleState.count !== 1) { throw new Error(`Expected one model picker view toggle, found ${toggleState.count}`); @@ -573,7 +681,9 @@ export class CodexCdpClient { ); return menu ? [...menu.querySelectorAll("[data-model-picker-view-toggle]")] : []; })()`, - "model picker view toggle" + "model picker view toggle", + trace, + "model.open-picker-view" ); } }; @@ -596,31 +706,53 @@ export class CodexCdpClient { text: String(item.textContent ?? "").replace(/\\s+/g, " ").trim(), checked: Boolean(item.querySelector("svg")) })); - })()`, "Codex model picker submenu"); + })()`, "Codex model picker submenu", trace, `model.wait-submenu-${rowIndex + 1}`); const identifyRows = async () => { - const rowCount = await this.evaluate(`(() => { + const rowCount = await runTraceStage(trace, "model.read-row-count", () => this.evaluate(`(() => { const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") ); return menu?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0; - })()`); + })()`)); if (rowCount !== 2) throw new Error(`Expected two Codex model picker rows, found ${rowCount}`); let modelRowIndex = -1; for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { await openMain(); - await this.clickRendererCandidates(rowExpression(rowIndex), `model picker row ${rowIndex + 1}`); + await this.clickRendererCandidates( + rowExpression(rowIndex), + `model picker row ${rowIndex + 1}`, + trace, + `model.open-row-${rowIndex + 1}` + ); const items = await submenuInfo(rowIndex); if (items.filter((item) => item.text === preset.displayName).length === 1) { modelRowIndex = rowIndex; } - await this.pressRendererEscape(); + await this.pressRendererEscape(trace, `model.close-row-${rowIndex + 1}`); } if (modelRowIndex < 0) throw new Error(`Codex model ${preset.displayName} is not available`); - return { modelRowIndex, effortRowIndex: modelRowIndex === 0 ? 1 : 0 }; + const result = { modelRowIndex, effortRowIndex: modelRowIndex === 0 ? 1 : 0 }; + trace?.record("model.rows-identified", { + rowCount, + outcome: "succeeded" + }); + return result; }; const selectEffort = async () => { const current = await readState(); - if (current.effort === preset.effort) return; + if (current.effort === preset.effort) { + trace?.record("model.effort", { + currentEffort: current.effort, + targetEffort: preset.effort, + outcome: "skipped" + }); + return; + } + trace?.record("model.effort", { + currentEffort: current.effort || "unknown", + targetEffort: preset.effort, + outcome: "changing" + }); const currentEffortIndex = effortOrder.indexOf(current.effort); if (currentEffortIndex < 0 || targetEffortIndex < 0) { throw new Error(`Unsupported Codex reasoning effort transition: ${current.effort} -> ${preset.effort}`); @@ -628,7 +760,12 @@ export class CodexCdpClient { await openMain(); const { effortRowIndex } = await identifyRows(); await openMain(); - await this.clickRendererCandidates(rowExpression(effortRowIndex), "reasoning effort row"); + await this.clickRendererCandidates( + rowExpression(effortRowIndex), + "reasoning effort row", + trace, + "model.open-effort-row" + ); const items = await submenuInfo(effortRowIndex); const checkedIndexes = items.flatMap((item, index) => item.checked ? [index] : []); if (items.length !== effortOrder.length || checkedIndexes.length !== 1 || checkedIndexes[0] !== currentEffortIndex) { @@ -644,18 +781,33 @@ export class CodexCdpClient { : null; const items = submenu ? [...submenu.querySelectorAll('[role="menuitem"]')] : []; return items[${targetEffortIndex}] ? [items[${targetEffortIndex}]] : []; - })()`, `reasoning effort ${preset.effort}`); + })()`, `reasoning effort ${preset.effort}`, trace, "model.select-effort-option"); await this.waitForRenderer( `document.querySelector("[data-codex-intelligence-trigger]")?.getAttribute("data-selected-reasoning-effort") === ${JSON.stringify(preset.effort)}`, - `reasoning effort ${preset.effort}` + `reasoning effort ${preset.effort}`, + trace, + "model.wait-effort-selected" ); + trace?.record("model.effort", { + targetEffort: preset.effort, + outcome: "succeeded" + }); }; const selectModel = async () => { - if ((await readState()).text.includes(preset.displayName)) return; + if ((await readState()).text.includes(preset.displayName)) { + trace?.record("model.model", { modelMatched: true, outcome: "skipped" }); + return; + } + trace?.record("model.model", { modelMatched: false, outcome: "changing" }); await openMain(); const { modelRowIndex } = await identifyRows(); await openMain(); - await this.clickRendererCandidates(rowExpression(modelRowIndex), "model row"); + await this.clickRendererCandidates( + rowExpression(modelRowIndex), + "model row", + trace, + "model.open-model-row" + ); const items = await submenuInfo(modelRowIndex); if (items.filter((item) => item.text === preset.displayName).length !== 1) { throw new Error(`Expected one available ${preset.displayName} model option`); @@ -673,25 +825,43 @@ export class CodexCdpClient { (item) => String(item.textContent ?? "").replace(/\\s+/g, " ").trim() === ${JSON.stringify(preset.displayName)} ) : []; - })()`, `model ${preset.displayName}`); + })()`, `model ${preset.displayName}`, trace, "model.select-model-option"); await this.waitForRenderer( `String(document.querySelector("[data-codex-intelligence-trigger]")?.textContent ?? "").includes(${JSON.stringify(preset.displayName)})`, - `model ${preset.displayName}` + `model ${preset.displayName}`, + trace, + "model.wait-model-selected" ); + trace?.record("model.model", { modelMatched: true, outcome: "succeeded" }); }; try { - await selectEffort(); - await selectModel(); - await selectEffort(); - const selected = await readState(); + await runTraceStage(trace, "model.select-effort-1", selectEffort, { targetEffort: preset.effort }); + await runTraceStage(trace, "model.select-model", selectModel, { action }); + await runTraceStage(trace, "model.select-effort-2", selectEffort, { targetEffort: preset.effort }); + const selected = await runTraceStage(trace, "model.validate", readState, { action }); if (!selected.text.includes(preset.displayName) || selected.effort !== preset.effort) { throw new Error(`Codex did not select ${preset.displayName} / ${preset.effort}`); } - await closeMenus(); + await runTraceStage(trace, "model.close-menus", closeMenus, { action }); + trace?.record("model.preset", { + action, + stage: "complete", + outcome: "succeeded", + targetEffort: preset.effort, + durationMs: Math.round(performance.now() - presetStartedAt) + }); return { model: preset.model, effort: preset.effort }; } catch (error) { - try { await closeMenus(); } catch {} + trace?.record("model.preset", { + action, + stage: "complete", + outcome: "failed", + category: traceErrorCategory(error), + targetEffort: preset.effort, + durationMs: Math.round(performance.now() - presetStartedAt) + }); + try { await runTraceStage(trace, "model.cleanup-menus", closeMenus, { action }); } catch {} throw error; } } @@ -801,8 +971,8 @@ export class CodexCdpClient { }, true); } - async clickRendererCandidates(candidatesExpression, description) { - await this.evaluate(`(() => { + async clickRendererCandidates(candidatesExpression, description, trace = null, stage = "renderer.click") { + await runTraceStage(trace, stage, () => this.evaluate(`(() => { const visible = (element) => { const rect = element?.getBoundingClientRect?.(); return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); @@ -822,26 +992,42 @@ export class CodexCdpClient { })); } return true; - })()`); + })()`)); await new Promise((resolve) => setTimeout(resolve, 80)); } - async pressRendererEscape() { - await this.evaluate(`(() => { + async pressRendererEscape(trace = null, stage = "renderer.escape") { + await runTraceStage(trace, stage, () => this.evaluate(`(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true })); return true; - })()`); + })()`)); await new Promise((resolve) => setTimeout(resolve, 50)); } - async waitForRenderer(expression, description) { + async waitForRenderer(expression, description, trace = null, stage = "renderer.wait") { + const startedAt = performance.now(); for (let attempt = 0; attempt < 20; attempt += 1) { const result = await this.evaluate(expression); - if (result) return result; + if (result) { + trace?.record("renderer.poll", { + stage, + outcome: "succeeded", + attempts: attempt + 1, + durationMs: Math.round(performance.now() - startedAt) + }); + return result; + } await new Promise((resolve) => setTimeout(resolve, 50)); } + trace?.record("renderer.poll", { + stage, + outcome: "failed", + category: "timeout", + attempts: 20, + durationMs: Math.round(performance.now() - startedAt) + }); throw new Error(`Timed out waiting for ${description}`); } diff --git a/src/bridge/platform.mjs b/src/bridge/platform.mjs index 06a2b43..a6e33dd 100644 --- a/src/bridge/platform.mjs +++ b/src/bridge/platform.mjs @@ -65,9 +65,7 @@ if (-not $candidate) { Select-Object -First 1 } if (-not $candidate) { throw 'A Codex Desktop window was not found.' } -$shell = New-Object -ComObject WScript.Shell -if (-not $shell.AppActivate($candidate.Id)) { - Add-Type @' +Add-Type @' using System; using System.Runtime.InteropServices; public static class CodexWindow { @@ -75,10 +73,9 @@ public static class CodexWindow { [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); } '@ - [CodexWindow]::ShowWindowAsync($candidate.MainWindowHandle, 9) | Out-Null - if (-not [CodexWindow]::SetForegroundWindow($candidate.MainWindowHandle)) { - throw 'Windows did not allow Codex Desktop to receive focus.' - } +[CodexWindow]::ShowWindowAsync($candidate.MainWindowHandle, 3) | Out-Null +if (-not [CodexWindow]::SetForegroundWindow($candidate.MainWindowHandle)) { + throw 'Windows did not allow Codex Desktop to receive focus.' } $candidate.Id `; @@ -97,7 +94,16 @@ foreach ($process in $processes) { Wait-Process -Id $process.ProcessId -Timeout `; function powershellArgs(command, extra = []) { - return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command, ...extra]; + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + command, + ...extra + ]; } function powershellOptions(options = {}) { diff --git a/src/bridge/server.mjs b/src/bridge/server.mjs index 6666ddc..fa6064d 100644 --- a/src/bridge/server.mjs +++ b/src/bridge/server.mjs @@ -1,5 +1,6 @@ import { createServer } from "node:http"; import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; import { CodexCdpClient } from "./codex-cdp.mjs"; @@ -9,6 +10,16 @@ import { decodeThreadPathSegment } from "./thread-key.mjs"; const HOST = "127.0.0.1"; const PORT = Number(process.env.CODEX_KEYBOARD_PORT || 17373); +const BRIDGE_VERSION = typeof __CODEX_BRIDGE_VERSION__ === "undefined" + ? null + : __CODEX_BRIDGE_VERSION__; +const RUNTIME_HASH = (() => { + try { + return createHash("sha256").update(readFileSync(process.argv[1])).digest("hex"); + } catch { + return null; + } +})(); const configuredRefreshMs = Number(process.env.CODEX_KEYBOARD_REFRESH_MS || 500); const REFRESH_MS = Number.isFinite(configuredRefreshMs) ? Math.max(250, configuredRefreshMs) @@ -32,6 +43,113 @@ let cached = { let refreshPromise = null; let nextReconnectAt = 0; let hasRefreshed = false; +const traceBuffer = new Map(); +const TRACE_TTL_MS = 30000; +const TRACE_LIMIT = 32; +const TRACE_EVENT_LIMIT = 160; + +function diagnosticErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timed out") || message.includes("timeout")) return "timeout"; + if (message.includes("disconnected") || message.includes("not running")) return "cdp-unavailable"; + if (message.includes("window")) return "focus-failed"; + if (message.includes("menu") || message.includes("model") || message.includes("reasoning")) return "renderer-state"; + return "bridge-operation"; +} + +function traceIdFrom(request) { + const value = String(request.headers["x-codex-trace-id"] || ""); + return /^[a-f0-9-]{36}$/.test(value) ? value : null; +} + +function safeTraceFields(fields) { + return Object.fromEntries(Object.entries(fields || {}).flatMap(([key, value]) => { + if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) { + return [[key, value]]; + } + const normalized = String(value || ""); + return /^[a-zA-Z0-9_.:-]{1,80}$/.test(normalized) ? [[key, normalized]] : []; + })); +} + +function startTrace(request, route, fields = {}) { + const traceId = traceIdFrom(request); + if (!traceId) return null; + const startedAt = performance.now(); + const trace = { + traceId, + startedAt, + events: [], + pending: 0, + completionRequested: false, + complete: false, + record(event, details = {}) { + if (!/^[a-z0-9.-]{1,80}$/.test(event) || this.events.length >= TRACE_EVENT_LIMIT) return; + this.events.push({ + event, + offsetMs: Math.round(performance.now() - startedAt), + ...safeTraceFields(details) + }); + }, + defer() { + this.pending += 1; + let settled = false; + return () => { + if (settled) return; + settled = true; + this.pending = Math.max(0, this.pending - 1); + if (this.completionRequested && this.pending === 0) this.complete = true; + }; + }, + finish() { + this.completionRequested = true; + if (this.pending === 0) this.complete = true; + } + }; + trace.record("server.request", { route, ...fields }); + traceBuffer.set(traceId, trace); + while (traceBuffer.size > TRACE_LIMIT) traceBuffer.delete(traceBuffer.keys().next().value); + const cleanup = setTimeout(() => traceBuffer.delete(traceId), TRACE_TTL_MS); + cleanup.unref?.(); + return trace; +} + +function traceSnapshot(traceId) { + const trace = traceBuffer.get(traceId); + if (!trace) return null; + return { + traceId, + complete: trace.complete, + events: trace.events.map(event => ({ ...event })) + }; +} + +async function focusCodexDesktop(trace = null) { + const startedAt = performance.now(); + const path = process.platform === "win32" ? "cdp" : "platform-adapter"; + trace?.record("focus.start", { platform: process.platform, path }); + try { + if (process.platform !== "win32") await focusCodex(); + else await client.focusWindow(trace); + trace?.record("focus.complete", { + platform: process.platform, + path, + outcome: "succeeded", + durationMs: Math.round(performance.now() - startedAt) + }); + console.log("Codex Desktop window maximized and focused through the local CDP bridge"); + } catch (error) { + trace?.record("focus.complete", { + platform: process.platform, + path, + outcome: "failed", + category: diagnosticErrorCategory(error), + durationMs: Math.round(performance.now() - startedAt) + }); + throw error; + } +} function authorized(request) { return bridgeRequestAuthorized(bridgeToken, request.headers.authorization); @@ -76,14 +194,27 @@ const server = createServer(async (request, response) => { if (!hasRefreshed || Date.now() - cached.updatedAt >= REFRESH_MS * 2) { await refresh(true); } - return json(response, 200, { ok: true, codexConnected: cached.connected, updatedAt: cached.updatedAt }); + return json(response, 200, { + ok: true, + bridgeVersion: BRIDGE_VERSION, + runtimeHash: RUNTIME_HASH, + codexConnected: cached.connected, + updatedAt: cached.updatedAt + }); } if (request.method === "GET" && url.pathname === "/state") { return json(response, 200, cached); } + if (request.method === "POST" && url.pathname === "/diagnostics/trace") { + const traceId = traceIdFrom(request); + const diagnostics = traceId ? traceSnapshot(traceId) : null; + return json(response, diagnostics ? 200 : 404, diagnostics + ? { ok: true, diagnostics } + : { ok: false, error: "Trace diagnostics are unavailable" }); + } if (request.method === "POST" && url.pathname === "/focus") { try { - await focusCodex(); + await focusCodexDesktop(); return json(response, 200, { ok: true }); } catch (error) { return json(response, 503, { ok: false, error: error.message }); @@ -94,7 +225,7 @@ const server = createServer(async (request, response) => { try { await Promise.all([ client.clickAgent(Number(match[1])), - focusCodex() + focusCodexDesktop() ]); return json(response, 200, { ok: true }); } catch (error) { @@ -105,41 +236,60 @@ const server = createServer(async (request, response) => { /^\/thread\/([^/]+)\/click$/ ); if (threadMatch) { + let trace = null; try { const threadId = decodeThreadPathSegment(threadMatch[1]); const slot = Number(url.searchParams.get("slot") || 0); if (!Number.isInteger(slot) || slot < 0 || slot > 5) { throw new Error("Invalid Codex Micro slot"); } + trace = startTrace(request, "thread-click", { slot: slot + 1 }); await Promise.all([ - client.clickThread(threadId, slot), - focusCodex() + client.clickThread(threadId, slot, trace), + focusCodexDesktop(trace) ]); + trace?.record("server.response", { outcome: "succeeded", route: "thread-click" }); return json(response, 200, { ok: true, bridge: true }); } catch (error) { + trace?.record("server.response", { + outcome: "failed", + route: "thread-click", + category: diagnosticErrorCategory(error) + }); return json(response, 503, { ok: false, bridge: false, error: error.message }); + } finally { + trace?.finish(); } } const action = request.method === "POST" && url.pathname.match( /^\/action\/(fast|approve|reject|pin|new|fork|mic|steer|submit|model-sol-high|model-luna-max|model-sol-medium)\/(down|up)$/ ); if (action) { + const trace = startTrace(request, "action", { action: action[1], phase: action[2] }); try { if (action[1] === "steer") { if (action[2] === "down") { - await focusCodex(); + await focusCodexDesktop(); await client.dispatchComposerSteer(); } return json(response, 200, { ok: true }); } - await client.dispatchNamedAction(action[1], action[2] === "down"); + await client.dispatchNamedAction(action[1], action[2] === "down", trace); + trace?.record("server.response", { outcome: "succeeded", route: "action" }); return json(response, 200, { ok: true, bridge: true }); } catch (error) { + trace?.record("server.response", { + outcome: "failed", + route: "action", + category: diagnosticErrorCategory(error) + }); return json(response, 503, { ok: false, error: error.message }); + } finally { + trace?.finish(); } } const joystick = request.method === "POST" && url.pathname.match( @@ -157,7 +307,7 @@ const server = createServer(async (request, response) => { }); server.listen(PORT, HOST, () => { - console.log(`Codex Keyboard bridge listening on http://${HOST}:${PORT}`); + console.log(`Codex Keyboard bridge ${BRIDGE_VERSION || "unversioned"} listening on http://${HOST}:${PORT}`); void refresh(); }); diff --git a/test/bridge-installer_test.mjs b/test/bridge-installer_test.mjs index 7bdb011..afae92a 100644 --- a/test/bridge-installer_test.mjs +++ b/test/bridge-installer_test.mjs @@ -1,11 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { access, chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { + acquireFilesystemLock, bridgeDataRoot, createBridgeInstaller, selectBridgeNodeRuntime @@ -15,6 +19,69 @@ const pluginRoot = fileURLToPath(new URL( "../integration/com.ulanzi.codexmicro.ulanziPlugin/", import.meta.url )); +const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); +const execFileAsync = promisify(execFile); + +async function sha256(path) { + return createHash("sha256").update(await readFile(path)).digest("hex"); +} + +test("filesystem locks stay owned and live owners are not treated as stale", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-micro-lock-owner-")); + const lockPath = join(root, "lifecycle.lock"); + try { + const releaseFirst = await acquireFilesystemLock({ lockPath }); + await assert.rejects( + acquireFilesystemLock({ lockPath, timeoutMs: 20, staleMs: 0, wait: () => Promise.resolve() }), + /still running/ + ); + + await rm(lockPath, { recursive: true, force: true }); + const releaseSecond = await acquireFilesystemLock({ lockPath }); + await releaseFirst(); + await access(lockPath); + await releaseSecond(); + await assert.rejects(access(lockPath)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("concurrent plugin installers serialize destination replacement", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-micro-plugin-install-lock-")); + const appData = join(root, "AppData"); + const localAppData = join(root, "LocalAppData"); + const environment = { + ...process.env, + APPDATA: appData, + LOCALAPPDATA: localAppData, + CODEX_BRIDGE_URL: "http://127.0.0.1:59995" + }; + try { + await Promise.all([ + execFileAsync(process.execPath, [join(repositoryRoot, "scripts", "install-plugin.mjs")], { + cwd: repositoryRoot, + env: environment + }), + execFileAsync(process.execPath, [join(repositoryRoot, "scripts", "install-plugin.mjs")], { + cwd: repositoryRoot, + env: environment + }) + ]); + const pluginsRoot = join(appData, "Ulanzi", "UlanziDeck", "Plugins"); + const installedManifest = JSON.parse(await readFile( + join(pluginsRoot, "com.ulanzi.codexmicro.ulanziPlugin", "manifest.json"), + "utf8" + )); + assert.equal(installedManifest.Version, "0.6.1"); + assert.deepEqual( + (await readdir(pluginsRoot)).filter(name => name.includes("installing-") || name.includes("backup-") || name.endsWith(".install.lock")), + [] + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); test("bundled installer creates and launches Codex Bridge without a repository path", async () => { const home = await mkdtemp(join(tmpdir(), "codex-micro-installer-")); @@ -24,9 +91,23 @@ test("bundled installer creates and launches Codex Bridge without a repository p await chmod(systemNode, 0o755); const resolvedSystemNode = await realpath(systemNode); const commands = []; + const bundledRuntimeHash = await sha256(join(pluginRoot, "installer", "bridge.mjs")); + let serviceOnline = false; + let runningVersion = "9.8.7"; + let runningRuntimeHash = bundledRuntimeHash; const health = createServer((_request, response) => { response.setHeader("Content-Type", "application/json"); - response.end(JSON.stringify({ ok: true, codexConnected: false })); + if (!serviceOnline) { + response.statusCode = 503; + response.end(JSON.stringify({ ok: false, error: "offline fixture" })); + return; + } + response.end(JSON.stringify({ + ok: true, + bridgeVersion: runningVersion, + runtimeHash: runningRuntimeHash, + codexConnected: false + })); }); await new Promise(resolve => health.listen(0, "127.0.0.1", resolve)); @@ -34,7 +115,13 @@ test("bundled installer creates and launches Codex Bridge without a repository p commands.push([command, args]); if (args[0] === "--version") return { stdout: "v22.14.0\n", stderr: "" }; if (command === "/bin/launchctl" && args[0] === "bootout") { - throw new Error("not loaded"); + serviceOnline = false; + return { stdout: "", stderr: "" }; + } + if (command === "/bin/launchctl" && args[0] === "bootstrap") { + serviceOnline = true; + runningVersion = "9.8.7"; + runningRuntimeHash = bundledRuntimeHash; } return { stdout: "", stderr: "" }; }; @@ -73,6 +160,13 @@ test("bundled installer creates and launches Codex Bridge without a repository p assert.equal(metadata.nodeSource, "system"); assert.ok(commands.some(([command, args]) => command === "/bin/launchctl" && args[0] === "bootstrap")); + runningVersion = "1.0.0"; + runningRuntimeHash = "stale-runtime"; + const updated = await installer.ensureCurrent(); + assert.equal(updated.needsUpdate, false); + assert.ok(commands.filter(([command, args]) => command === "/bin/launchctl" && args[0] === "bootout").length >= 1); + assert.ok(commands.filter(([command, args]) => command === "/bin/launchctl" && args[0] === "bootstrap").length >= 2); + await installer.launch(); assert.ok(commands.some(([command]) => command === "/usr/bin/open")); @@ -119,10 +213,15 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr const bin = join(home, "bin"); const systemNode = join(bin, "node.exe"); const codexExecutable = join(home, "WindowsApps", "OpenAI.Codex_1.2.3.4_x64", "app", "ChatGPT.exe"); + const bridgeRuntime = join(localAppData, "OpenCodexMicro", "bridge.mjs"); + const bundledRuntimeHash = await sha256(join(pluginRoot, "installer", "bridge.mjs")); await mkdir(bin, { recursive: true }); await writeFile(systemNode, "fake node"); const commands = []; const children = []; + let serviceOnline = false; + let runningVersion = "9.8.7"; + let runningRuntimeHash = bundledRuntimeHash; const execute = async (command, args, options) => { commands.push([command, args, options]); if (args[0] === "--version") return { stdout: "v22.14.0\n", stderr: "" }; @@ -137,11 +236,19 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr stderr: "" }; } + if (command === "powershell.exe" && args.join(" ").includes("CODEX_BRIDGE_TARGET_RUNTIME")) { + serviceOnline = false; + } return { stdout: "", stderr: "" }; }; const spawnProcess = (command, args, options) => { children.push({ command, args, options }); - return { unref() {} }; + if (args[0] === bridgeRuntime) { + serviceOnline = true; + runningVersion = "9.8.7"; + runningRuntimeHash = bundledRuntimeHash; + } + return { pid: 1000 + children.length, unref() {} }; }; const installer = createBridgeInstaller({ pluginRoot, @@ -154,7 +261,23 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr environmentPath: bin, execute, spawnProcess, - fetchImpl: async () => { throw new Error("offline fixture"); } + fetchImpl: async url => { + if (String(url).endsWith("/health") && serviceOnline) { + return { + ok: true, + async json() { + return { + ok: true, + bridgeVersion: runningVersion, + runtimeHash: runningRuntimeHash, + codexConnected: false + }; + } + }; + } + throw new Error("offline fixture"); + }, + serviceStartTimeoutMs: 500 }); try { @@ -174,15 +297,28 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr assert.equal(children[0].command, await realpath(systemNode)); assert.equal(children[0].options.windowsHide, true); assert.equal(children[0].options.env.CODEX_BRIDGE_TOKEN, token); + assert.equal(await readFile(join(dataRoot, "bridge.pid"), "utf8"), "1001\n"); + const metadata = JSON.parse(await readFile(join(dataRoot, "install.json"), "utf8")); + assert.equal(metadata.runtimeHash, bundledRuntimeHash); + + runningVersion = "0.5.0"; + runningRuntimeHash = "stale-runtime"; + const reconciled = await installer.ensureCurrent(); + assert.equal(reconciled.needsUpdate, false); + assert.equal(children[1].command, await realpath(systemNode)); + assert.equal(await readFile(join(dataRoot, "bridge.pid"), "utf8"), "1002\n"); + assert.ok(commands.some(([command, args]) => + command === "powershell.exe" && args.join(" ").includes("CODEX_BRIDGE_TARGET_RUNTIME") + )); await installer.launch(); - assert.equal(children[1].command, codexExecutable); - assert.deepEqual(children[1].args, [ + assert.equal(children[2].command, codexExecutable); + assert.deepEqual(children[2].args, [ "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--remote-allow-origins=http://127.0.0.1:9222" ]); - assert.equal(children[1].options.windowsHide, false); + assert.equal(children[2].options.windowsHide, false); assert.ok(commands.some(([command, args]) => command === "powershell.exe" && args.join(" ").includes("Stop-Process") )); @@ -211,3 +347,291 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr await rm(home, { recursive: true, force: true }); } }); + +test("a failed Windows Bridge restart restores the previous runtime and metadata", async () => { + const home = await mkdtemp(join(tmpdir(), "codex-micro-windows-rollback-")); + const localAppData = join(home, "LocalAppData"); + const dataRoot = join(localAppData, "OpenCodexMicro"); + const bridgeRuntime = join(dataRoot, "bridge.mjs"); + const systemNode = join(home, "bin", "node.exe"); + const oldRuntime = Buffer.from("previous bridge runtime"); + const oldRuntimeHash = createHash("sha256").update(oldRuntime).digest("hex"); + let serviceOnline = true; + let runningVersion = "1.0.0"; + let runningRuntimeHash = oldRuntimeHash; + let bridgeStarts = 0; + + await mkdir(join(home, "bin"), { recursive: true }); + await mkdir(dataRoot, { recursive: true }); + await writeFile(systemNode, "fake node"); + const resolvedNode = await realpath(systemNode); + await writeFile(bridgeRuntime, oldRuntime); + await writeFile(join(dataRoot, "bridge-token"), "synthetic-test-token\n"); + const previousMetadata = { + version: "1.0.0", + runtimeHash: oldRuntimeHash, + platform: "win32", + codexChannel: "stable", + nodeExecutable: resolvedNode, + nodeVersion: "22.14.0", + nodeSource: "system", + installedAt: "2026-01-01T00:00:00.000Z" + }; + await writeFile(join(dataRoot, "install.json"), "malformed previous metadata\n"); + + const installer = createBridgeInstaller({ + pluginRoot, + bridgeUrl: "http://127.0.0.1:59998", + version: "9.8.7", + home, + localAppData, + platform: "win32", + nodeExecutable: process.execPath, + environmentPath: join(home, "bin"), + execute: async (command, args) => { + if (args[0] === "--version") return { stdout: "v22.14.0\n", stderr: "" }; + if (command === "powershell.exe" && args.join(" ").includes("CODEX_BRIDGE_TARGET_RUNTIME")) { + serviceOnline = false; + } + return { stdout: "", stderr: "" }; + }, + spawnProcess: (_command, args) => { + if (args[0] === bridgeRuntime) { + bridgeStarts += 1; + serviceOnline = true; + if (bridgeStarts === 1) { + runningVersion = "9.8.7"; + runningRuntimeHash = "unexpected-runtime"; + } else { + runningVersion = "1.0.0"; + runningRuntimeHash = oldRuntimeHash; + } + } + return { pid: 2000 + bridgeStarts, unref() {} }; + }, + fetchImpl: async url => { + if (String(url).endsWith("/health") && serviceOnline) { + return { + ok: true, + async json() { + return { + ok: true, + bridgeVersion: runningVersion, + runtimeHash: runningRuntimeHash, + codexConnected: false + }; + } + }; + } + throw new Error("offline fixture"); + }, + serviceStartTimeoutMs: 20, + wait: () => new Promise(resolve => setTimeout(resolve, 1)) + }); + + try { + await assert.rejects(installer.install(), /Previous runtime restored/); + assert.deepEqual(await readFile(bridgeRuntime), oldRuntime); + const recoveredMetadata = JSON.parse(await readFile(join(dataRoot, "install.json"), "utf8")); + assert.equal(recoveredMetadata.version, previousMetadata.version); + assert.equal(recoveredMetadata.runtimeHash, previousMetadata.runtimeHash); + assert.equal(recoveredMetadata.nodeExecutable, previousMetadata.nodeExecutable); + assert.equal(recoveredMetadata.recovered, true); + assert.equal(bridgeStarts, 2); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test("separate installer instances serialize the same Windows Bridge update", async () => { + const home = await mkdtemp(join(tmpdir(), "codex-micro-windows-lock-")); + const localAppData = join(home, "LocalAppData"); + const dataRoot = join(localAppData, "OpenCodexMicro"); + const bridgeRuntime = join(dataRoot, "bridge.mjs"); + const systemNode = join(home, "bin", "node.exe"); + const oldRuntime = Buffer.from("stale bridge runtime"); + const oldRuntimeHash = createHash("sha256").update(oldRuntime).digest("hex"); + const bundledRuntimeHash = await sha256(join(pluginRoot, "installer", "bridge.mjs")); + let serviceOnline = true; + let runningVersion = "1.0.0"; + let runningRuntimeHash = oldRuntimeHash; + let bridgeStarts = 0; + + await mkdir(join(home, "bin"), { recursive: true }); + await mkdir(dataRoot, { recursive: true }); + await writeFile(systemNode, "fake node"); + const resolvedNode = await realpath(systemNode); + await writeFile(bridgeRuntime, oldRuntime); + await writeFile(join(dataRoot, "bridge-token"), "synthetic-test-token\n"); + await writeFile(join(dataRoot, "install.json"), `${JSON.stringify({ + version: "1.0.0", + runtimeHash: oldRuntimeHash, + platform: "win32", + codexChannel: "stable", + nodeExecutable: resolvedNode, + nodeVersion: "22.14.0", + nodeSource: "system", + installedAt: "2026-01-01T00:00:00.000Z" + }, null, 2)}\n`); + + const dependencies = { + pluginRoot, + bridgeUrl: "http://127.0.0.1:59997", + version: "9.8.7", + home, + localAppData, + platform: "win32", + nodeExecutable: process.execPath, + environmentPath: join(home, "bin"), + execute: async (command, args) => { + if (args[0] === "--version") return { stdout: "v22.14.0\n", stderr: "" }; + if (command === "powershell.exe" && args.join(" ").includes("CODEX_BRIDGE_TARGET_RUNTIME")) { + serviceOnline = false; + } + return { stdout: "", stderr: "" }; + }, + spawnProcess: () => { + bridgeStarts += 1; + serviceOnline = true; + runningVersion = "9.8.7"; + runningRuntimeHash = bundledRuntimeHash; + return { pid: 3000 + bridgeStarts, unref() {} }; + }, + fetchImpl: async url => { + if (String(url).endsWith("/health") && serviceOnline) { + return { + ok: true, + async json() { + return { + ok: true, + bridgeVersion: runningVersion, + runtimeHash: runningRuntimeHash, + codexConnected: false + }; + } + }; + } + throw new Error("offline fixture"); + }, + serviceStartTimeoutMs: 500 + }; + + try { + const first = createBridgeInstaller(dependencies); + const second = createBridgeInstaller(dependencies); + const results = await Promise.all([first.ensureCurrent(), second.ensureCurrent()]); + assert.ok(results.every(result => result.needsUpdate === false)); + assert.equal(bridgeStarts, 1); + assert.equal(await readFile(join(dataRoot, "bridge.pid"), "utf8"), "3001\n"); + + await rm(join(dataRoot, "bridge-token"), { force: true }); + const repaired = await first.ensureCurrent(); + assert.equal(repaired.needsUpdate, false); + assert.equal(bridgeStarts, 2); + assert.match((await readFile(join(dataRoot, "bridge-token"), "utf8")).trim(), /^[A-Za-z0-9_-]{40,}$/); + await assert.rejects(access(join(localAppData, ".OpenCodexMicro.lifecycle.lock"))); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test("an interrupted Windows update restores both backups when the new runtime cannot restart", async () => { + const home = await mkdtemp(join(tmpdir(), "codex-micro-windows-interrupted-")); + const localAppData = join(home, "LocalAppData"); + const dataRoot = join(localAppData, "OpenCodexMicro"); + const bridgeRuntime = join(dataRoot, "bridge.mjs"); + const installMetadata = join(dataRoot, "install.json"); + const systemNode = join(home, "bin", "node.exe"); + const bundledRuntime = await readFile(join(pluginRoot, "installer", "bridge.mjs")); + const bundledRuntimeHash = createHash("sha256").update(bundledRuntime).digest("hex"); + const oldRuntime = Buffer.from("runtime before interrupted update"); + const oldRuntimeHash = createHash("sha256").update(oldRuntime).digest("hex"); + let serviceOnline = false; + let runningVersion = null; + let runningRuntimeHash = null; + let bridgeStarts = 0; + + await mkdir(join(home, "bin"), { recursive: true }); + await mkdir(dataRoot, { recursive: true }); + await writeFile(systemNode, "fake node"); + const resolvedNode = await realpath(systemNode); + const metadata = (version, runtimeHash) => ({ + version, + runtimeHash, + platform: "win32", + codexChannel: "stable", + nodeExecutable: resolvedNode, + nodeVersion: "22.14.0", + nodeSource: "system", + installedAt: "2026-01-01T00:00:00.000Z" + }); + const oldMetadata = metadata("1.0.0", oldRuntimeHash); + await writeFile(bridgeRuntime, bundledRuntime); + await writeFile(installMetadata, `${JSON.stringify(metadata("9.8.7", bundledRuntimeHash), null, 2)}\n`); + await writeFile(join(dataRoot, ".bridge.mjs.previous"), oldRuntime); + await writeFile(join(dataRoot, ".install.json.previous"), `${JSON.stringify(oldMetadata, null, 2)}\n`); + await writeFile(join(dataRoot, "bridge-token"), "synthetic-test-token\n"); + + const installer = createBridgeInstaller({ + pluginRoot, + bridgeUrl: "http://127.0.0.1:59996", + version: "9.8.7", + home, + localAppData, + platform: "win32", + nodeExecutable: process.execPath, + environmentPath: join(home, "bin"), + execute: async (command, args) => { + if (args[0] === "--version") return { stdout: "v22.14.0\n", stderr: "" }; + if (command === "powershell.exe" && args.join(" ").includes("CODEX_BRIDGE_TARGET_RUNTIME")) { + serviceOnline = false; + } + return { stdout: "", stderr: "" }; + }, + spawnProcess: () => { + bridgeStarts += 1; + serviceOnline = true; + if (bridgeStarts === 1) { + runningVersion = "9.8.7"; + runningRuntimeHash = "failed-new-runtime"; + } else if (bridgeStarts === 2) { + runningVersion = "1.0.0"; + runningRuntimeHash = oldRuntimeHash; + } else { + runningVersion = "9.8.7"; + runningRuntimeHash = bundledRuntimeHash; + } + return { pid: 4000 + bridgeStarts, unref() {} }; + }, + fetchImpl: async url => { + if (String(url).endsWith("/health") && serviceOnline) { + return { + ok: true, + async json() { + return { + ok: true, + bridgeVersion: runningVersion, + runtimeHash: runningRuntimeHash, + codexConnected: false + }; + } + }; + } + throw new Error("offline fixture"); + }, + serviceStartTimeoutMs: 20, + wait: () => new Promise(resolve => setTimeout(resolve, 1)) + }); + + try { + const reconciled = await installer.ensureCurrent(); + assert.equal(reconciled.needsUpdate, false); + assert.deepEqual(await readFile(bridgeRuntime), bundledRuntime); + assert.equal(JSON.parse(await readFile(installMetadata, "utf8")).version, "9.8.7"); + assert.equal(bridgeStarts, 3); + await assert.rejects(access(join(dataRoot, ".bridge.mjs.previous"))); + await assert.rejects(access(join(dataRoot, ".install.json.previous"))); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/test/bridge_test.mjs b/test/bridge_test.mjs index 3617c17..aae926c 100644 --- a/test/bridge_test.mjs +++ b/test/bridge_test.mjs @@ -24,6 +24,25 @@ import { bridgeRequestAuthorized } from "../src/bridge/auth.mjs"; const UUID = "f6805b8a-332a-43a0-a118-52d3e59542f6"; +function createTraceFixture() { + const events = []; + let pending = 0; + return { + events, + get pending() { return pending; }, + record(event, fields = {}) { events.push({ event, ...fields }); }, + defer() { + pending += 1; + let settled = false; + return () => { + if (settled) return; + settled = true; + pending -= 1; + }; + } + }; +} + test("Bridge write authorization fails closed and accepts only the installed token", () => { const token = "fixture-local-capability-token"; assert.equal(bridgeRequestAuthorized("", `Bearer ${token}`), false); @@ -69,7 +88,7 @@ test("Windows CDP and Appx discovery accept Stable and Beta process shapes", asy assert.equal(appxOptions.windowsHide, true); }); -test("Windows background PowerShell calls hide their console windows", async () => { +test("Windows background PowerShell calls stay hidden and maximize on fallback focus", async () => { const calls = []; const execute = async (command, args, options) => { calls.push({ command, args, options }); @@ -91,18 +110,80 @@ test("Windows background PowerShell calls hide their console windows", async () await focusCodex({ platform: "win32", execute }); await focusCodex({ platform: "win32", execute }); assert.equal(calls.length, 3); - assert.ok(calls.every(({ command, options }) => - command === "powershell.exe" && options?.windowsHide === true + assert.ok(calls.every(({ command, args, options }) => + command === "powershell.exe" && + options?.windowsHide === true && + args.includes("-WindowStyle") && + args.includes("Hidden") )); const focusCalls = calls.slice(1); assert.ok(focusCalls.every(({ args, options }) => - args.join(" ").includes("AppActivate") && + args.join(" ").includes("ShowWindowAsync($candidate.MainWindowHandle, 3)") && args.join(" ").includes("SetForegroundWindow") && options.timeout === 5000 )); assert.equal(focusCalls[1].options.env.CODEX_BRIDGE_FOCUS_PID, "4242"); }); +test("CDP focus maximizes the Codex window before bringing it to the foreground", async () => { + const client = new CodexCdpClient(); + const calls = []; + const trace = createTraceFixture(); + client.connect = async () => {}; + client.sendCommand = async (method, params) => { + calls.push({ method, params }); + return method === "Browser.getWindowForTarget" ? { windowId: 42 } : {}; + }; + + await client.focusWindow(trace); + + assert.deepEqual(calls, [ + { method: "Browser.getWindowForTarget", params: {} }, + { + method: "Browser.setWindowBounds", + params: { windowId: 42, bounds: { windowState: "maximized" } } + }, + { method: "Page.bringToFront", params: {} } + ]); + assert.deepEqual( + trace.events.filter(event => event.outcome === "succeeded").map(event => event.stage), + ["focus.connect", "focus.get-window", "focus.maximize", "focus.bring-to-front"] + ); +}); + +test("task tracing covers native press, detached release, and DOM activation without thread data", async () => { + const client = new CodexCdpClient(); + const trace = createTraceFixture(); + const calls = []; + client.dispatchAgent = async (slot, _threadKey, act) => calls.push(["agent", slot, act]); + client.activateThread = async () => calls.push(["activate"]); + + await client.clickThreadKey("local:private-thread-fixture", 2, trace); + await new Promise(resolve => setTimeout(resolve, 70)); + + assert.deepEqual(calls, [["agent", 2, 1], ["agent", 2, 0], ["activate"]]); + assert.equal(trace.pending, 0); + assert.ok(trace.events.some(event => event.stage === "task.native-act1" && event.outcome === "succeeded")); + assert.ok(trace.events.some(event => event.stage === "task.native-act0" && event.background === true)); + assert.ok(trace.events.some(event => event.stage === "task.dom-activate" && event.background === true)); + assert.doesNotMatch(JSON.stringify(trace.events), /private-thread-fixture/); +}); + +test("renderer polling traces attempt count and elapsed stage without expression text", async () => { + const client = new CodexCdpClient(); + const trace = createTraceFixture(); + const results = [false, false, true]; + client.evaluate = async () => results.shift(); + + await client.waitForRenderer("private-renderer-expression", "fixture menu", trace, "model.wait-fixture"); + + const poll = trace.events.find(event => event.event === "renderer.poll"); + assert.equal(poll?.stage, "model.wait-fixture"); + assert.equal(poll?.attempts, 3); + assert.equal(poll?.outcome, "succeeded"); + assert.doesNotMatch(JSON.stringify(trace.events), /private-renderer-expression/); +}); + test("concurrent CDP connects share one socket and ignore stale socket events", async () => { class FakeSocket extends EventEmitter { readyState = 0; From db989d7ac3644c020490cd8cba4a0f962c04118f Mon Sep 17 00:00:00 2001 From: yyf <2358929807@qq.com> Date: Fri, 21 Aug 2026 12:57:47 +0800 Subject: [PATCH 7/8] fix: bind Windows focus to the active Codex process --- THIRD_PARTY_NOTICES.md | 26 ++ .../build.mjs | 36 +++ .../installer/THIRD_PARTY_NOTICES.md | 26 ++ .../native-runtime/native-runtime.json | 44 +++ .../plugin/app.js | 4 +- .../plugin/bridge-installer.js | 125 +++++++- .../test/smoke.mjs | 15 +- package.json | 5 +- scripts/install-plugin.mjs | 4 +- src/bridge/codex-cdp.mjs | 80 +++-- src/bridge/navigation.mjs | 5 + src/bridge/platform.mjs | 157 +++++----- src/bridge/server.mjs | 54 +++- src/bridge/windows-focus.mjs | 215 +++++++++++++ test/bridge-installer_test.mjs | 35 ++- test/bridge_test.mjs | 283 ++++++++++++++---- test/windows-native-runtime_test.mjs | 20 ++ 17 files changed, 934 insertions(+), 200 deletions(-) create mode 100644 integration/com.ulanzi.codexmicro.ulanziPlugin/installer/native-runtime/native-runtime.json create mode 100644 src/bridge/navigation.mjs create mode 100644 src/bridge/windows-focus.mjs create mode 100644 test/windows-native-runtime_test.mjs diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2ee2354..9431442 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -37,6 +37,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## Koffi + +- Project: https://koffi.dev/ +- Use: Low-overhead in-process Win32 FFI for Codex Desktop window activation +- License: MIT + +Copyright 2023 Niels Martignène + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## esbuild - Project: https://github.com/evanw/esbuild diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs index e5f8733..1aa625e 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs @@ -1,10 +1,13 @@ +import { createHash } from "node:crypto"; import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { build } from "esbuild"; import { resolve } from "node:path"; const packageRoot = resolve("."); const repositoryRoot = resolve(packageRoot, "../.."); +const workspaceRoot = resolve(repositoryRoot, "../.."); const installerRoot = resolve(packageRoot, "installer"); +const nativeRuntimeRoot = resolve(installerRoot, "native-runtime"); const manifest = JSON.parse(await readFile(resolve(packageRoot, "manifest.json"), "utf8")); await mkdir("dist", { recursive: true }); @@ -27,6 +30,7 @@ await build({ outfile: resolve(installerRoot, "bridge.mjs"), platform: "node", target: "node20", + external: ["koffi"], define: { __CODEX_BRIDGE_VERSION__: JSON.stringify(String(manifest.Version)) }, @@ -34,6 +38,38 @@ await build({ js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" } }); + +const nativeRuntimeFiles = [ + ["koffi/index.cjs", "node_modules/koffi/index.cjs"], + ["koffi/src/koffi/index.cjs", "node_modules/koffi/src/koffi/index.cjs"], + ["koffi/src/koffi/src/static.cjs", "node_modules/koffi/src/koffi/src/static.cjs"], + ["koffi/package.json", "node_modules/koffi/package.json"], + ["koffi/LICENSE.txt", "node_modules/koffi/LICENSE.txt"], + ["@koromix/koffi-win32-x64/index.js", "node_modules/@koromix/koffi-win32-x64/index.js"], + ["@koromix/koffi-win32-x64/package.json", "node_modules/@koromix/koffi-win32-x64/package.json"], + ["@koromix/koffi-win32-x64/README.md", "node_modules/@koromix/koffi-win32-x64/README.md"], + ["@koromix/koffi-win32-x64/win32_x64/koffi.node", "node_modules/@koromix/koffi-win32-x64/win32_x64/koffi.node"] +]; +await rm(nativeRuntimeRoot, { recursive: true, force: true }); +const manifestFiles = []; +for (const [sourceRelative, destinationRelative] of nativeRuntimeFiles) { + const source = resolve(workspaceRoot, "node_modules", sourceRelative); + const destination = resolve(nativeRuntimeRoot, destinationRelative); + await mkdir(resolve(destination, ".."), { recursive: true }); + await copyFile(source, destination); + const sha256 = createHash("sha256").update(await readFile(destination)).digest("hex"); + manifestFiles.push({ path: destinationRelative.replaceAll("\\", "/"), sha256 }); +} +const nativeRuntimeHash = createHash("sha256") + .update(manifestFiles.map(file => `${file.path}:${file.sha256}`).join("\n")) + .digest("hex"); +await writeFile(resolve(nativeRuntimeRoot, "native-runtime.json"), `${JSON.stringify({ + version: 1, + runtimeHash: nativeRuntimeHash, + platform: "win32", + architecture: "x64", + files: manifestFiles +}, null, 2)}\n`); await copyFile(resolve(repositoryRoot, "bridge/CodexBridge.png"), resolve(installerRoot, "CodexBridge.png")); for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { await copyFile(resolve(repositoryRoot, notice), resolve(installerRoot, notice)); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/THIRD_PARTY_NOTICES.md b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/THIRD_PARTY_NOTICES.md index 2ee2354..9431442 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/THIRD_PARTY_NOTICES.md +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/THIRD_PARTY_NOTICES.md @@ -37,6 +37,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## Koffi + +- Project: https://koffi.dev/ +- Use: Low-overhead in-process Win32 FFI for Codex Desktop window activation +- License: MIT + +Copyright 2023 Niels Martignène + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## esbuild - Project: https://github.com/evanw/esbuild diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/native-runtime/native-runtime.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/native-runtime/native-runtime.json new file mode 100644 index 0000000..9576ccc --- /dev/null +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/native-runtime/native-runtime.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "runtimeHash": "30b86d3469b290b7b0fa99a44d65c94e4ba91b0f95fa9789f594ef645f896785", + "platform": "win32", + "architecture": "x64", + "files": [ + { + "path": "node_modules/koffi/index.cjs", + "sha256": "a200e15ec168d00cb3155794808a98243051c61dd7f6989c23c57ee220c918b9" + }, + { + "path": "node_modules/koffi/src/koffi/index.cjs", + "sha256": "ac18369dc4c0b2401a32587ce1f04c07754403ad62253db53b356530dd9ecec6" + }, + { + "path": "node_modules/koffi/src/koffi/src/static.cjs", + "sha256": "25dff303c876cfdb531e2d9590ecbbb4928c8e60453c484faad66b2a3abd1556" + }, + { + "path": "node_modules/koffi/package.json", + "sha256": "d775f96fcc02d4417543c0344a5af26ff42284992f81e54400d451d07ec76aa4" + }, + { + "path": "node_modules/koffi/LICENSE.txt", + "sha256": "22bcbbbacd739b026a8dc484f62a31484ce4c4e31f3408b60b29b4e41d62b290" + }, + { + "path": "node_modules/@koromix/koffi-win32-x64/index.js", + "sha256": "f9991ec70a0f775487ffadc2461d69edd1555e3d9bf80de0bbdd2eec7d245c49" + }, + { + "path": "node_modules/@koromix/koffi-win32-x64/package.json", + "sha256": "5de9e2da3ead5a3f53bb68222b7f4b21abab0a437993bb48af066bbb91530647" + }, + { + "path": "node_modules/@koromix/koffi-win32-x64/README.md", + "sha256": "4fc87635ba2ce995d9f91401e96c68dd0600ca3db25dae419a9abc4e8e11fcd0" + }, + { + "path": "node_modules/@koromix/koffi-win32-x64/win32_x64/koffi.node", + "sha256": "3f92d8794a051e2b873e8201bcbb66b0049e4b44d93a76e38ec9b1e588976ebe" + } + ] +} diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js index 98d4d8c..27e4200 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/app.js @@ -302,9 +302,9 @@ function logShortcut(instance, shortcut, level, event, fields = []) { function safeDiagnosticFields(event) { const allowed = new Set([ - "action", "attempts", "background", "category", "complete", "connection", + "action", "attempts", "background", "category", "channel", "complete", "connection", "currentEffort", "durationMs", "effortMatched", "modelMatched", "outcome", - "path", "phase", "platform", "reused", "route", "rowCount", "slot", "stage", + "focusOk", "path", "phase", "platform", "reused", "route", "rowCount", "slot", "stage", "targetEffort" ]); return Object.entries(event || {}).flatMap(([key, value]) => { diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js index 6290122..bd4b818 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js @@ -2,6 +2,7 @@ import { constants as fsConstants } from "node:fs"; import { access, chmod, + cp, copyFile, mkdir, readFile, @@ -17,8 +18,7 @@ import { promisify } from "node:util"; import { homedir } from "node:os"; import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; import { - discoverDebugPort, - focusCodex, + discoverDebugEndpoint, launchWindowsCodex } from "../../../src/bridge/platform.mjs"; @@ -58,6 +58,29 @@ async function fileSha256(path) { } } +async function verifiedNativeRuntimeHash(root) { + const manifest = await readJson(join(root, "native-runtime.json")); + if ( + manifest?.version !== 1 || + !/^[a-f0-9]{64}$/.test(String(manifest?.runtimeHash || "")) || + !Array.isArray(manifest?.files) + ) { + return null; + } + const verified = []; + for (const file of manifest.files) { + const relative = String(file?.path || "").replaceAll("\\", "/"); + if (!relative || relative.startsWith("/") || relative.split("/").includes("..")) return null; + const sha256 = await fileSha256(join(root, ...relative.split("/"))); + if (!sha256 || sha256 !== file.sha256) return null; + verified.push({ path: relative, sha256 }); + } + const computed = createHash("sha256") + .update(verified.map(file => `${file.path}:${file.sha256}`).join("\n")) + .digest("hex"); + return computed === manifest.runtimeHash ? computed : null; +} + function processIsAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { @@ -221,6 +244,8 @@ export function createBridgeInstaller({ const installerRoot = resolve(pluginRoot, "installer"); const bundledRuntime = join(installerRoot, "bridge.mjs"); const bundledIcon = join(installerRoot, "CodexBridge.png"); + const bundledNativeRuntime = join(installerRoot, "native-runtime"); + const nativeRuntimesRoot = join(appRoot, "native-runtimes"); const runtimeBackup = join(appRoot, ".bridge.mjs.previous"); const metadataBackup = join(appRoot, ".install.json.previous"); @@ -281,6 +306,7 @@ export function createBridgeInstaller({ cdpConnected: Boolean(payload.codexConnected), serviceVersion: typeof payload.bridgeVersion === "string" ? payload.bridgeVersion : null, serviceRuntimeHash: typeof payload.runtimeHash === "string" ? payload.runtimeHash : null, + serviceNativeRuntimeHash: typeof payload.nativeRuntimeHash === "string" ? payload.nativeRuntimeHash : null, serviceError: null }; } catch (error) { @@ -289,6 +315,7 @@ export function createBridgeInstaller({ cdpConnected: false, serviceVersion: null, serviceRuntimeHash: null, + serviceNativeRuntimeHash: null, serviceError: error.message }; } @@ -303,11 +330,22 @@ export function createBridgeInstaller({ fileSha256(bundledRuntime), fileSha256(bridgeRuntime) ]); + const bundledNativeRuntimeHash = platform === "win32" + ? await verifiedNativeRuntimeHash(bundledNativeRuntime) + : null; + const installedNativeRuntimeHash = platform === "win32" && metadata?.nativeRuntimeHash + ? await verifiedNativeRuntimeHash(join(nativeRuntimesRoot, metadata.nativeRuntimeHash)) + : null; + const nativeRuntimeInstalled = platform !== "win32" || Boolean( + bundledNativeRuntimeHash && + installedNativeRuntimeHash === bundledNativeRuntimeHash && + metadata?.nativeRuntimeHash === bundledNativeRuntimeHash + ); const macAppInstalled = platform === "darwin" ? await exists(bridgeExecutable, fsConstants.X_OK) : runtimeInstalled; const agentInstalled = platform === "darwin" ? await exists(bridgeAgent) : true; - const installed = macAppInstalled && runtimeInstalled && tokenInstalled && agentInstalled; + const installed = macAppInstalled && runtimeInstalled && tokenInstalled && agentInstalled && nativeRuntimeInstalled; const installationDetected = Boolean( runtimeInstalled || tokenInstalled || metadata || probe.serviceOnline || (platform === "darwin" && (macAppInstalled || agentInstalled)) @@ -317,11 +355,13 @@ export function createBridgeInstaller({ metadata?.version === version && metadata?.runtimeHash === bundledRuntimeHash && installedRuntimeHash === bundledRuntimeHash + && nativeRuntimeInstalled ); const runningMatchesBundle = !probe.serviceOnline || Boolean( bundledRuntimeHash && probe.serviceVersion === version && - probe.serviceRuntimeHash === bundledRuntimeHash + probe.serviceRuntimeHash === bundledRuntimeHash && + (platform !== "win32" || probe.serviceNativeRuntimeHash === bundledNativeRuntimeHash) ); return { supported: platform === "win32" || (platform === "darwin" && Number.isInteger(uid)), @@ -334,6 +374,8 @@ export function createBridgeInstaller({ bundledVersion: version, bundledRuntimeHash, installedRuntimeHash, + bundledNativeRuntimeHash, + installedNativeRuntimeHash, needsUpdate: !installed || !metadataMatchesBundle || !runningMatchesBundle, appPath: platform === "darwin" ? bridgeApp : appRoot, nodeExecutable: metadata?.nodeExecutable || null, @@ -593,7 +635,11 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi return true; } - async function waitForExpectedService(expectedRuntimeHash, expectedVersion = version) { + async function waitForExpectedService( + expectedRuntimeHash, + expectedVersion = version, + expectedNativeRuntimeHash = null + ) { const deadline = Date.now() + serviceStartTimeoutMs; let lastProbe = null; do { @@ -601,7 +647,8 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi if ( lastProbe.serviceOnline && lastProbe.serviceVersion === expectedVersion && - lastProbe.serviceRuntimeHash === expectedRuntimeHash + lastProbe.serviceRuntimeHash === expectedRuntimeHash && + (platform !== "win32" || lastProbe.serviceNativeRuntimeHash === expectedNativeRuntimeHash) ) { return lastProbe; } @@ -617,7 +664,11 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi if (!(platform === "win32" || (platform === "darwin" && Number.isInteger(uid)))) { throw new Error("Codex Bridge installation is supported on Windows and macOS only."); } - if (!await exists(bundledRuntime) || !await exists(bundledIcon)) { + if ( + !await exists(bundledRuntime) || + !await exists(bundledIcon) || + (platform === "win32" && !await verifiedNativeRuntimeHash(bundledNativeRuntime)) + ) { throw new Error("The plugin does not contain the Codex Bridge installation resources."); } const nodeRuntime = await selectBridgeNodeRuntime({ @@ -634,6 +685,22 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi : null; const previousMetadataText = await readFile(installMetadata, "utf8").catch(() => null); const previousMetadata = await readJson(installMetadata); + const nativeRuntimeHash = platform === "win32" ? previousStatus.bundledNativeRuntimeHash : null; + const nativeRuntime = nativeRuntimeHash ? join(nativeRuntimesRoot, nativeRuntimeHash) : null; + let createdNativeRuntime = false; + if (nativeRuntimeHash && await verifiedNativeRuntimeHash(nativeRuntime) !== nativeRuntimeHash) { + const stagedNativeRuntime = join(appRoot, `.native-runtime.installing-${process.pid}`); + await rm(stagedNativeRuntime, { recursive: true, force: true }); + await cp(bundledNativeRuntime, stagedNativeRuntime, { recursive: true }); + if (await verifiedNativeRuntimeHash(stagedNativeRuntime) !== nativeRuntimeHash) { + await rm(stagedNativeRuntime, { recursive: true, force: true }); + throw new Error("The bundled native focus runtime could not be verified."); + } + await mkdir(nativeRuntimesRoot, { recursive: true }); + await rm(nativeRuntime, { recursive: true, force: true }); + await rename(stagedNativeRuntime, nativeRuntime); + createdNativeRuntime = true; + } const stagedRuntime = join(appRoot, `.bridge.mjs.installing-${process.pid}`); await mkdir(appRoot, { recursive: true, mode: 0o700 }); if (platform !== "win32") await chmod(appRoot, 0o700); @@ -656,6 +723,7 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi await commitMetadata(`${JSON.stringify({ version, runtimeHash, + nativeRuntimeHash, platform, codexChannel, nodeExecutable: nodeRuntime.executable, @@ -665,7 +733,7 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi }, null, 2)}\n`); if (platform === "darwin") await installMac(nodeRuntime); if (platform === "win32") await ensureServiceUnlocked(); - await waitForExpectedService(runtimeHash); + await waitForExpectedService(runtimeHash, version, nativeRuntimeHash); const installed = await status(); if (installed.needsUpdate) throw new Error("The restarted Codex Bridge did not match the bundled runtime."); await discardTransactionBackups(); @@ -682,6 +750,7 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi const recoveryMetadataText = previousMetadata ? previousMetadataText : `${JSON.stringify({ version: recoveryVersion, runtimeHash: previousRuntimeHash, + nativeRuntimeHash: previousMetadata?.nativeRuntimeHash || null, platform, codexChannel: previousStatus.codexChannel || codexChannel, nodeExecutable: nodeRuntime.executable, @@ -710,6 +779,13 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi await rm(appRoot, { recursive: true, force: true }); resetAuthorizationCache(); } + if ( + createdNativeRuntime && + nativeRuntime && + previousMetadata?.nativeRuntimeHash !== nativeRuntimeHash + ) { + await rm(nativeRuntime, { recursive: true, force: true }); + } } catch (rollbackFailure) { rollbackError = rollbackFailure; } @@ -729,7 +805,14 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi const probe = await probeBridge(); if (probe.serviceOnline || Date.now() - lastWindowsServiceStart < 2000) return { ...await status(), ...probe }; const metadata = await readJson(installMetadata); - if (!metadata?.nodeExecutable || !await exists(bridgeRuntime) || !await exists(tokenPath)) { + const nativeRuntimeValid = !metadata?.nativeRuntimeHash || + await verifiedNativeRuntimeHash(join(nativeRuntimesRoot, metadata.nativeRuntimeHash)) === metadata.nativeRuntimeHash; + if ( + !metadata?.nodeExecutable || + !await exists(bridgeRuntime) || + !await exists(tokenPath) || + !nativeRuntimeValid + ) { throw new Error("Codex Bridge is not installed. Use Install / Repair first."); } const token = (await readFile(tokenPath, "utf8")).trim(); @@ -740,7 +823,11 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi env: { ...process.env, CODEX_BRIDGE_DATA_ROOT: appRoot, - CODEX_BRIDGE_TOKEN: token + CODEX_BRIDGE_TOKEN: token, + ...(metadata.nativeRuntimeHash ? { + CODEX_BRIDGE_NATIVE_ROOT: join(nativeRuntimesRoot, metadata.nativeRuntimeHash), + CODEX_BRIDGE_NATIVE_HASH: metadata.nativeRuntimeHash + } : {}) } }); if (Number.isInteger(child.pid) && child.pid > 0) { @@ -774,7 +861,11 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi if (!current.serviceOnline) { try { await startInstalledService(await readJson(installMetadata)); - await waitForExpectedService(current.bundledRuntimeHash); + await waitForExpectedService( + current.bundledRuntimeHash, + version, + current.bundledNativeRuntimeHash + ); const ready = await status(); await discardTransactionBackups(); return ready; @@ -801,8 +892,16 @@ if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi if (platform === "win32") { await ensureService(); try { - await discoverDebugPort({ platform, execute, fetchImpl }); - await focusCodex({ platform, execute }); + await discoverDebugEndpoint({ platform, execute, fetchImpl }); + const response = await fetchImpl(`${bridgeUrl}/focus`, { + method: "POST", + headers: await authorizationHeaders(), + signal: AbortSignal.timeout(1200) + }); + const payload = await response.json(); + if (!response.ok || payload?.ok !== true) { + throw new Error(payload?.error || "Codex focus request failed"); + } } catch (error) { if (!/local debug bridge/.test(error.message)) throw error; await launchWindowsCodex({ channel: codexChannel, execute, spawnProcess }); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs index c0d48c3..41a5c77 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs @@ -12,6 +12,10 @@ const bundledBridgeSource = await readFile(new URL("installer/bridge.mjs", packa const bundledBridgeHash = createHash("sha256") .update(bundledBridgeSource) .digest("hex"); +const nativeRuntimeManifest = JSON.parse(await readFile( + new URL("installer/native-runtime/native-runtime.json", packageRootUrl), + "utf8" +)); const navigateAction = manifest.Actions.find(action => action.UUID === "com.ulanzi.ulanzistudio.codexmicro.navigate" ); @@ -62,7 +66,9 @@ for (const asset of [ "installer/CodexBridge.png", "installer/LICENSE", "installer/NOTICE.md", - "installer/THIRD_PARTY_NOTICES.md" + "installer/THIRD_PARTY_NOTICES.md", + "installer/native-runtime/native-runtime.json", + "installer/native-runtime/node_modules/@koromix/koffi-win32-x64/win32_x64/koffi.node" ]) { assert.ok((await readFile(new URL(asset, packageRootUrl))).length > 0, `${asset} must be bundled`); } @@ -153,6 +159,7 @@ const bridge = createServer((request, response) => { ok: true, bridgeVersion: manifest.Version, runtimeHash: bundledBridgeHash, + nativeRuntimeHash: nativeRuntimeManifest.runtimeHash, codexConnected: true, updatedAt: Date.now() })); @@ -182,7 +189,7 @@ const bridge = createServer((request, response) => { events: route === "thread-click" ? [ { event: "server.request", offsetMs: 0, route, slot: 1 }, - { event: "cdp.stage", offsetMs: 12, stage: "focus.bring-to-front", outcome: "succeeded", durationMs: 4 }, + { event: "focus.native", offsetMs: 12, outcome: "succeeded", channel: "beta", reused: false }, { event: "cdp.stage", offsetMs: 51, stage: "task.dom-activate", outcome: "succeeded", durationMs: 9, background: true } ] : [ @@ -436,8 +443,8 @@ try { ); assert.match(failedPresetLog?.message || "", /category=bridge-rejected/); const serializedLogs = JSON.stringify(messages.filter(message => message.cmd === "logMessage")); - assert.match(serializedLogs, /bridgeEvent=cdp\.stage/); - assert.match(serializedLogs, /stage=focus\.bring-to-front/); + assert.match(serializedLogs, /bridgeEvent=focus\.native/); + assert.match(serializedLogs, /channel=beta/); assert.match(serializedLogs, /stage=task\.dom-activate/); assert.match(serializedLogs, /bridgeEvent=renderer\.poll/); assert.match(serializedLogs, /stage=model\.wait-main-menu/); diff --git a/package.json b/package.json index f9f7dd0..4ef07a0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test": "node --test test/*_test.mjs", "build:bridge": "node scripts/build-bridge.mjs", "build:plugin": "npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run build", - "check:node": "node --check scripts/install.mjs && node --check scripts/install-plugin.mjs && node --check scripts/launch.mjs && node --check scripts/uninstall.mjs && node --check scripts/build-bridge.mjs && node --check src/bridge/thread-key.mjs && node --check src/bridge/auth.mjs && node --check src/bridge/platform.mjs && node --check src/bridge/codex-cdp.mjs && node --check src/bridge/server.mjs", + "check:node": "node --check scripts/install.mjs && node --check scripts/install-plugin.mjs && node --check scripts/launch.mjs && node --check scripts/uninstall.mjs && node --check scripts/build-bridge.mjs && node --check src/bridge/thread-key.mjs && node --check src/bridge/auth.mjs && node --check src/bridge/windows-focus.mjs && node --check src/bridge/navigation.mjs && node --check src/bridge/platform.mjs && node --check src/bridge/codex-cdp.mjs && node --check src/bridge/server.mjs", "check:plugin": "npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run check && npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run smoke", "check": "npm run test && npm run check:node && npm run build:bridge && npm run build:plugin && npm run check:plugin", "setup": "node scripts/install.mjs", @@ -20,6 +20,9 @@ "bridge:start": "node scripts/launch.mjs", "uninstall": "node scripts/uninstall.mjs" }, + "dependencies": { + "koffi": "3.1.5" + }, "devDependencies": { "esbuild": "^0.25.12", "ws": "^8.21.1" diff --git a/scripts/install-plugin.mjs b/scripts/install-plugin.mjs index aa21fa7..c6b3274 100644 --- a/scripts/install-plugin.mjs +++ b/scripts/install-plugin.mjs @@ -68,7 +68,9 @@ for (const relative of [ "installer/CodexBridge.png", "installer/LICENSE", "installer/NOTICE.md", - "installer/THIRD_PARTY_NOTICES.md" + "installer/THIRD_PARTY_NOTICES.md", + "installer/native-runtime/native-runtime.json", + "installer/native-runtime/node_modules/@koromix/koffi-win32-x64/win32_x64/koffi.node" ]) { if (!await exists(join(source, relative))) { throw new Error(`Bundled Bridge resource is missing: ${relative}`); diff --git a/src/bridge/codex-cdp.mjs b/src/bridge/codex-cdp.mjs index b0d770d..039b5c4 100644 --- a/src/bridge/codex-cdp.mjs +++ b/src/bridge/codex-cdp.mjs @@ -1,4 +1,4 @@ -import { discoverDebugPort, fetchJson } from "./platform.mjs"; +import { discoverDebugEndpoint, fetchJson } from "./platform.mjs"; import WebSocket from "ws"; import { localThreadKey } from "./thread-key.mjs"; @@ -397,9 +397,12 @@ export class CodexCdpClient { nextId = 0; pending = new Map(); lastSnapshot = null; + connectionIdentity = null; + modelActionQueue = Promise.resolve(); + modelPickerLayout = null; constructor({ - discoverPort = discoverDebugPort, + discoverPort = discoverDebugEndpoint, fetchTargets = fetchJson, createSocket = url => new WebSocket(url) } = {}) { @@ -422,12 +425,25 @@ export class CodexCdpClient { } async openConnection(generation) { - const port = await this.discoverPort(); + const discovery = await this.discoverPort(); + const identity = Number.isInteger(discovery) + ? { port: discovery, processId: null, executable: null, channel: null } + : discovery; + const port = Number(identity?.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error("Codex debug endpoint identity was invalid"); + } const target = selectMainTarget(await this.fetchTargets(`http://127.0.0.1:${port}/json/list`)); if (!target?.webSocketDebuggerUrl) throw new Error("Codex main renderer was not found"); if (generation !== this.connectionGeneration) throw new Error("Codex bridge connection was cancelled"); const socket = this.createSocket(target.webSocketDebuggerUrl); this.socket = socket; + this.connectionIdentity = { + port, + processId: Number.isInteger(identity?.processId) ? identity.processId : null, + executable: typeof identity?.executable === "string" ? identity.executable : null, + channel: ["stable", "beta"].includes(identity?.channel) ? identity.channel : null + }; try { await new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -472,25 +488,6 @@ export class CodexCdpClient { } } - async focusWindow(trace = null) { - await runTraceStage(trace, "focus.connect", () => this.connect(), { - connection: this.socket?.readyState === WebSocket.OPEN ? "reused" : "open" - }); - const { windowId } = await runTraceStage( - trace, - "focus.get-window", - () => this.sendCommand("Browser.getWindowForTarget", {}) - ); - if (!Number.isInteger(windowId)) { - throw new Error("Codex Desktop window handle was not available"); - } - await runTraceStage(trace, "focus.maximize", () => this.sendCommand("Browser.setWindowBounds", { - windowId, - bounds: { windowState: "maximized" } - })); - await runTraceStage(trace, "focus.bring-to-front", () => this.sendCommand("Page.bringToFront", {})); - } - async clickAgent(slot) { await this.connect(); const snapshot = this.lastSnapshot ?? await this.snapshot(); @@ -579,11 +576,16 @@ export class CodexCdpClient { } async dispatchRendererAction(action, trace = null) { - await runTraceStage(trace, "model.connect", () => this.connect(), { action }); if (MODEL_PRESETS[action]) { - await this.dispatchModelPreset(action, trace); + const operation = this.modelActionQueue.then(async () => { + await runTraceStage(trace, "model.connect", () => this.connect(), { action }); + return this.dispatchModelPreset(action, trace); + }); + this.modelActionQueue = operation.catch(() => {}); + await operation; return true; } + await runTraceStage(trace, "model.connect", () => this.connect(), { action }); const invoked = await this.evaluate(rendererActionExpression(action)); if (!invoked) throw new Error(`Codex ${action} action is not available`); return true; @@ -685,6 +687,12 @@ export class CodexCdpClient { trace, "model.open-picker-view" ); + await this.waitForRenderer(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return menu?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length === 2; + })()`, "expanded Codex model picker", trace, "model.wait-picker-view"); } }; const rowExpression = (rowIndex) => `(() => { @@ -708,6 +716,15 @@ export class CodexCdpClient { })); })()`, "Codex model picker submenu", trace, `model.wait-submenu-${rowIndex + 1}`); const identifyRows = async () => { + if (this.modelPickerLayout?.connectionGeneration === this.connectionGeneration) { + trace?.record("model.rows-identified", { + rowCount: 2, + source: "cache", + outcome: "succeeded" + }); + return this.modelPickerLayout; + } + this.modelPickerLayout = null; const rowCount = await runTraceStage(trace, "model.read-row-count", () => this.evaluate(`(() => { const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") @@ -731,9 +748,15 @@ export class CodexCdpClient { await this.pressRendererEscape(trace, `model.close-row-${rowIndex + 1}`); } if (modelRowIndex < 0) throw new Error(`Codex model ${preset.displayName} is not available`); - const result = { modelRowIndex, effortRowIndex: modelRowIndex === 0 ? 1 : 0 }; + const result = { + connectionGeneration: this.connectionGeneration, + modelRowIndex, + effortRowIndex: modelRowIndex === 0 ? 1 : 0 + }; + this.modelPickerLayout = result; trace?.record("model.rows-identified", { rowCount, + source: "probe", outcome: "succeeded" }); return result; @@ -836,9 +859,8 @@ export class CodexCdpClient { }; try { - await runTraceStage(trace, "model.select-effort-1", selectEffort, { targetEffort: preset.effort }); await runTraceStage(trace, "model.select-model", selectModel, { action }); - await runTraceStage(trace, "model.select-effort-2", selectEffort, { targetEffort: preset.effort }); + await runTraceStage(trace, "model.select-effort", selectEffort, { targetEffort: preset.effort }); const selected = await runTraceStage(trace, "model.validate", readState, { action }); if (!selected.text.includes(preset.displayName) || selected.effort !== preset.effort) { throw new Error(`Codex did not select ${preset.displayName} / ${preset.effort}`); @@ -853,6 +875,7 @@ export class CodexCdpClient { }); return { model: preset.model, effort: preset.effort }; } catch (error) { + this.modelPickerLayout = null; trace?.record("model.preset", { action, stage: "complete", @@ -993,7 +1016,6 @@ export class CodexCdpClient { } return true; })()`)); - await new Promise((resolve) => setTimeout(resolve, 80)); } async pressRendererEscape(trace = null, stage = "renderer.escape") { @@ -1054,6 +1076,8 @@ export class CodexCdpClient { disconnect() { this.connectionGeneration += 1; this.connectPromise = null; + this.modelPickerLayout = null; + this.connectionIdentity = null; const socket = this.socket; this.socket = null; if (socket && socket.readyState !== WebSocket.CLOSED) { diff --git a/src/bridge/navigation.mjs b/src/bridge/navigation.mjs new file mode 100644 index 0000000..f10f023 --- /dev/null +++ b/src/bridge/navigation.mjs @@ -0,0 +1,5 @@ +export async function navigateAndFocus(navigate, focus) { + const [navigation, activation] = await Promise.allSettled([navigate(), focus()]); + if (navigation.status === "rejected") throw navigation.reason; + return { focusOk: activation.status === "fulfilled" }; +} diff --git a/src/bridge/platform.mjs b/src/bridge/platform.mjs index a6e33dd..27cfe8f 100644 --- a/src/bridge/platform.mjs +++ b/src/bridge/platform.mjs @@ -2,8 +2,6 @@ import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -let windowsFocusPid = null; -let windowsFocusPromise = null; export const CDP_HOST = "127.0.0.1"; export const DEFAULT_CDP_PORT = 9222; @@ -15,9 +13,23 @@ export const CDP_ARGUMENTS = Object.freeze([ const WINDOWS_PROCESS_COMMAND = String.raw` $ErrorActionPreference = 'Stop' -Get-CimInstance Win32_Process | +$listenerOwners = @{} +Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalAddress -eq '127.0.0.1' } | + ForEach-Object { $listenerOwners[('{0}:{1}' -f $_.LocalPort, $_.OwningProcess)] = $true } +$rows = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' } | - ForEach-Object { $_.CommandLine } + ForEach-Object { + $portMatch = [regex]::Match([string]$_.CommandLine, '--remote-debugging-port(?:=|\s+)(\d+)') + $debugPort = if ($portMatch.Success) { [int]$portMatch.Groups[1].Value } else { 0 } + [pscustomobject]@{ + processId = [int]$_.ProcessId + executable = [string]$_.ExecutablePath + commandLine = [string]$_.CommandLine + ownsDebugPort = $debugPort -gt 0 -and $listenerOwners.ContainsKey(('{0}:{1}' -f $debugPort, $_.ProcessId)) + } + } +@($rows) | ConvertTo-Json -Compress `; const WINDOWS_PACKAGE_COMMAND = String.raw` @@ -47,39 +59,6 @@ $rows = foreach ($name in @('OpenAI.Codex', 'OpenAI.CodexBeta')) { @($rows) | ConvertTo-Json -Compress `; -const WINDOWS_FOCUS_COMMAND = String.raw` -$ErrorActionPreference = 'Stop' -$preferredPid = 0 -$hasPreferredPid = [int]::TryParse($env:CODEX_BRIDGE_FOCUS_PID, [ref]$preferredPid) -$candidate = if ($hasPreferredPid -and $preferredPid -gt 0) { - Get-Process -Id $preferredPid -ErrorAction SilentlyContinue | - Where-Object { $_.MainWindowHandle -ne 0 } -} else { $null } -if (-not $candidate) { - $debugPids = @(Get-CimInstance Win32_Process | - Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' -and $_.CommandLine -notlike '*--type=*' } | - Select-Object -ExpandProperty ProcessId) - $candidate = Get-Process | - Where-Object { $_.MainWindowHandle -ne 0 -and ($_.ProcessName -like 'ChatGPT*' -or $_.ProcessName -like 'Codex*') } | - Sort-Object @{ Expression = { if ($debugPids -contains $_.Id) { 0 } else { 1 } } }, StartTime | - Select-Object -First 1 -} -if (-not $candidate) { throw 'A Codex Desktop window was not found.' } -Add-Type @' -using System; -using System.Runtime.InteropServices; -public static class CodexWindow { - [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); - [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); -} -'@ -[CodexWindow]::ShowWindowAsync($candidate.MainWindowHandle, 3) | Out-Null -if (-not [CodexWindow]::SetForegroundWindow($candidate.MainWindowHandle)) { - throw 'Windows did not allow Codex Desktop to receive focus.' -} -$candidate.Id -`; - const WINDOWS_STOP_EXECUTABLE_COMMAND = String.raw` $ErrorActionPreference = 'Stop' $target = $env:CODEX_BRIDGE_TARGET_EXECUTABLE @@ -111,13 +90,42 @@ function powershellOptions(options = {}) { } export function debugPortsFromCommandLines(text) { - const ports = []; - for (const line of String(text || "").split(/\r?\n/)) { - if (!line.includes("--remote-debugging-address=127.0.0.1")) continue; - const port = Number(line.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); - if (Number.isInteger(port) && port > 0 && port <= 65535) ports.push(port); + return [...new Set(debugProcessesFromCommandLines(text).map(item => item.port))]; +} + +function processChannel(executable, commandLine) { + const identity = `${executable || ""} ${commandLine || ""}`.toLowerCase(); + return /codexbeta|chatgpt\s*\(beta\)|codex\s*\(beta\)/.test(identity) ? "beta" : "stable"; +} + +export function debugProcessesFromCommandLines(text) { + const source = String(text || "").trim(); + let rows = []; + if (source.startsWith("[") || source.startsWith("{")) { + try { + const parsed = JSON.parse(source); + rows = Array.isArray(parsed) ? parsed : [parsed]; + } catch {} } - return [...new Set(ports)]; + if (rows.length === 0) { + rows = source.split(/\r?\n/).filter(Boolean).map(commandLine => ({ commandLine })); + } + return rows.flatMap(row => { + const commandLine = String(row?.commandLine || ""); + if (!commandLine.includes("--remote-debugging-address=127.0.0.1")) return []; + if (commandLine.includes("--type=")) return []; + const port = Number(commandLine.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); + if (!Number.isInteger(port) || port <= 0 || port > 65535) return []; + const processId = Number(row?.processId); + const executable = typeof row?.executable === "string" ? row.executable : null; + return [{ + port, + processId: Number.isInteger(processId) && processId > 0 ? processId : null, + executable, + channel: processChannel(executable, commandLine), + ownsDebugPort: row?.ownsDebugPort === true + }]; + }); } export async function fetchJson(url, timeout = 1200, fetchImpl = fetch) { @@ -140,31 +148,39 @@ async function processCommandLines(platform, execute) { return ""; } -export async function discoverDebugPort({ +export async function discoverDebugEndpoint({ platform = process.platform, execute = execFileAsync, fetchImpl = fetch, preferredPort = DEFAULT_CDP_PORT } = {}) { + let processes = []; try { - await fetchJson(`http://${CDP_HOST}:${preferredPort}/json/version`, 500, fetchImpl); - return preferredPort; - } catch {} - const candidates = []; - try { - candidates.push(...debugPortsFromCommandLines(await processCommandLines(platform, execute))); + processes = debugProcessesFromCommandLines(await processCommandLines(platform, execute)); } catch { // Direct loopback probing remains available when process inspection is denied. } - for (const port of [...new Set(candidates)].filter((port) => port !== preferredPort)) { + const candidates = [preferredPort, ...processes.map(item => item.port)]; + for (const port of [...new Set(candidates)]) { try { await fetchJson(`http://${CDP_HOST}:${port}/json/version`, 500, fetchImpl); - return port; + const process = processes.find(item => item.port === port && item.ownsDebugPort) + ?? processes.find(item => item.port === port); + return { + port, + processId: process?.processId ?? null, + executable: process?.executable ?? null, + channel: process?.channel ?? null + }; } catch {} } throw new Error("Codex is not running with the local debug bridge"); } +export async function discoverDebugPort(options = {}) { + return (await discoverDebugEndpoint(options)).port; +} + export async function discoverWindowsCodexExecutables({ execute = execFileAsync } = {}) { const { stdout = "" } = await execute( "powershell.exe", @@ -180,35 +196,16 @@ export async function discoverWindowsCodexExecutables({ execute = execFileAsync ); } -export async function focusCodex({ platform = process.platform, execute = execFileAsync } = {}) { +export async function focusCodex({ + platform = process.platform, + execute = execFileAsync, + processId = null, + activateWindows = null +} = {}) { if (platform === "win32") { - if (windowsFocusPromise) return windowsFocusPromise; - const operation = (async () => { - try { - const { stdout = "" } = await execute( - "powershell.exe", - powershellArgs(WINDOWS_FOCUS_COMMAND), - powershellOptions({ - timeout: 5000, - env: { - ...process.env, - ...(windowsFocusPid ? { CODEX_BRIDGE_FOCUS_PID: String(windowsFocusPid) } : {}) - } - }) - ); - const focusedPid = Number(String(stdout).trim().split(/\r?\n/).at(-1)); - windowsFocusPid = Number.isInteger(focusedPid) && focusedPid > 0 ? focusedPid : null; - } catch (error) { - windowsFocusPid = null; - throw error; - } - })(); - windowsFocusPromise = operation; - try { - return await operation; - } finally { - if (windowsFocusPromise === operation) windowsFocusPromise = null; - } + const activate = activateWindows + ?? (await import("./windows-focus.mjs")).activateWindowsProcess; + return activate(processId); } if (platform === "darwin") { await execute("/usr/bin/open", ["-b", "com.openai.codex"], { timeout: 3000 }); diff --git a/src/bridge/server.mjs b/src/bridge/server.mjs index fa6064d..309c166 100644 --- a/src/bridge/server.mjs +++ b/src/bridge/server.mjs @@ -6,6 +6,7 @@ import { join } from "node:path"; import { CodexCdpClient } from "./codex-cdp.mjs"; import { bridgeRequestAuthorized } from "./auth.mjs"; import { focusCodex } from "./platform.mjs"; +import { navigateAndFocus } from "./navigation.mjs"; import { decodeThreadPathSegment } from "./thread-key.mjs"; const HOST = "127.0.0.1"; @@ -20,6 +21,9 @@ const RUNTIME_HASH = (() => { return null; } })(); +const NATIVE_RUNTIME_HASH = /^[a-f0-9]{64}$/.test(String(process.env.CODEX_BRIDGE_NATIVE_HASH || "")) + ? process.env.CODEX_BRIDGE_NATIVE_HASH + : null; const configuredRefreshMs = Number(process.env.CODEX_KEYBOARD_REFRESH_MS || 500); const REFRESH_MS = Number.isFinite(configuredRefreshMs) ? Math.max(250, configuredRefreshMs) @@ -127,18 +131,33 @@ function traceSnapshot(traceId) { async function focusCodexDesktop(trace = null) { const startedAt = performance.now(); - const path = process.platform === "win32" ? "cdp" : "platform-adapter"; + const path = process.platform === "win32" ? "win32-native" : "platform-adapter"; trace?.record("focus.start", { platform: process.platform, path }); try { - if (process.platform !== "win32") await focusCodex(); - else await client.focusWindow(trace); + let identity = null; + if (process.platform === "win32") { + const connection = client.socket?.readyState === 1 ? "reused" : "open"; + await client.connect(); + identity = client.connectionIdentity; + trace?.record("focus.target", { + connection, + channel: identity?.channel || "unknown", + outcome: "succeeded" + }); + } + const result = await focusCodex({ processId: identity?.processId ?? null }); + trace?.record("focus.native", { + outcome: "succeeded", + channel: identity?.channel || "unknown", + reused: Boolean(result?.alreadyForeground && result?.alreadyMaximized) + }); trace?.record("focus.complete", { platform: process.platform, path, outcome: "succeeded", durationMs: Math.round(performance.now() - startedAt) }); - console.log("Codex Desktop window maximized and focused through the local CDP bridge"); + console.log("Codex Desktop window maximized and focused through the native desktop adapter"); } catch (error) { trace?.record("focus.complete", { platform: process.platform, @@ -198,6 +217,7 @@ const server = createServer(async (request, response) => { ok: true, bridgeVersion: BRIDGE_VERSION, runtimeHash: RUNTIME_HASH, + nativeRuntimeHash: NATIVE_RUNTIME_HASH, codexConnected: cached.connected, updatedAt: cached.updatedAt }); @@ -223,11 +243,11 @@ const server = createServer(async (request, response) => { const match = request.method === "POST" && url.pathname.match(/^\/agent\/([0-5])\/click$/); if (match) { try { - await Promise.all([ - client.clickAgent(Number(match[1])), - focusCodexDesktop() - ]); - return json(response, 200, { ok: true }); + const result = await navigateAndFocus( + () => client.clickAgent(Number(match[1])), + () => focusCodexDesktop() + ); + return json(response, 200, { ok: true, ...result }); } catch (error) { return json(response, 503, { ok: false, error: error.message }); } @@ -244,12 +264,16 @@ const server = createServer(async (request, response) => { throw new Error("Invalid Codex Micro slot"); } trace = startTrace(request, "thread-click", { slot: slot + 1 }); - await Promise.all([ - client.clickThread(threadId, slot, trace), - focusCodexDesktop(trace) - ]); - trace?.record("server.response", { outcome: "succeeded", route: "thread-click" }); - return json(response, 200, { ok: true, bridge: true }); + const result = await navigateAndFocus( + () => client.clickThread(threadId, slot, trace), + () => focusCodexDesktop(trace) + ); + trace?.record("server.response", { + outcome: "succeeded", + route: "thread-click", + focusOk: result.focusOk + }); + return json(response, 200, { ok: true, bridge: true, ...result }); } catch (error) { trace?.record("server.response", { outcome: "failed", diff --git a/src/bridge/windows-focus.mjs b/src/bridge/windows-focus.mjs new file mode 100644 index 0000000..ddf4643 --- /dev/null +++ b/src/bridge/windows-focus.mjs @@ -0,0 +1,215 @@ +import { createRequire } from "node:module"; +import { join } from "node:path"; + +const SW_MAXIMIZE = 3; +const FLASHW_TRAY = 0x00000002; +const FLASHW_TIMERNOFG = 0x0000000C; +const cachedWindows = new Map(); +let windowsApiPromise = null; + +function sameWindow(left, right) { + return left != null && right != null && String(left) === String(right); +} + +async function loadKoffi() { + const nativeRoot = process.env.CODEX_BRIDGE_NATIVE_ROOT; + if (nativeRoot) { + return createRequire(join(nativeRoot, "bridge-native.cjs"))( + join(nativeRoot, "node_modules", "koffi", "index.cjs") + ); + } + return (await import("koffi")).default; +} + +async function createWindowsApi() { + if (process.platform !== "win32") { + throw new Error("Native Codex window activation is available on Windows only"); + } + const koffi = await loadKoffi(); + const user32 = koffi.load("user32.dll"); + const kernel32 = koffi.load("kernel32.dll"); + const HANDLE = koffi.pointer("HANDLE", koffi.opaque()); + koffi.alias("HWND", HANDLE); + const FLASHWINFO = koffi.struct("FLASHWINFO", { + cbSize: "uint32_t", + hwnd: "HWND", + dwFlags: "uint32_t", + uCount: "uint32_t", + dwTimeout: "uint32_t" + }); + const findWindowEx = user32.func( + "HWND __stdcall FindWindowExW(HWND parent, HWND childAfter, const char16_t *className, const char16_t *windowName)" + ); + const getWindowThreadProcessId = user32.func( + "uint32_t __stdcall GetWindowThreadProcessId(HWND hwnd, _Out_ uint32_t *processId)" + ); + const isWindow = user32.func("bool __stdcall IsWindow(HWND hwnd)"); + const isWindowVisible = user32.func("bool __stdcall IsWindowVisible(HWND hwnd)"); + const getWindowTextLength = user32.func("int __stdcall GetWindowTextLengthW(HWND hwnd)"); + const isZoomed = user32.func("bool __stdcall IsZoomed(HWND hwnd)"); + const showWindowAsync = user32.func("bool __stdcall ShowWindowAsync(HWND hwnd, int command)"); + const setForegroundWindow = user32.func("bool __stdcall SetForegroundWindow(HWND hwnd)"); + const getForegroundWindow = user32.func("HWND __stdcall GetForegroundWindow()"); + const bringWindowToTop = user32.func("bool __stdcall BringWindowToTop(HWND hwnd)"); + const setFocus = user32.func("HWND __stdcall SetFocus(HWND hwnd)"); + const attachThreadInput = user32.func( + "bool __stdcall AttachThreadInput(uint32_t sourceThreadId, uint32_t targetThreadId, bool attach)" + ); + const getCurrentThreadId = kernel32.func("uint32_t __stdcall GetCurrentThreadId()"); + const flashWindowEx = user32.func("bool __stdcall FlashWindowEx(_Inout_ FLASHWINFO *info)"); + + function identityForWindow(hwnd) { + const processId = [0]; + const threadId = getWindowThreadProcessId(hwnd, processId); + return { + processId: Number(processId[0]) || 0, + threadId: Number(threadId) || 0 + }; + } + + return { + isWindow, + isZoomed, + getForegroundWindow, + processIdForWindow(hwnd) { + return identityForWindow(hwnd).processId; + }, + findWindowForProcess(processId) { + let previous = null; + for (let index = 0; index < 4096; index += 1) { + const hwnd = findWindowEx(null, previous, null, null); + if (hwnd == null) return null; + previous = hwnd; + if ( + identityForWindow(hwnd).processId === processId && + isWindow(hwnd) && + isWindowVisible(hwnd) && + getWindowTextLength(hwnd) > 0 + ) { + return hwnd; + } + } + throw new Error("Windows window enumeration exceeded its safety bound"); + }, + maximize(hwnd) { + return showWindowAsync(hwnd, SW_MAXIMIZE); + }, + foreground(hwnd) { + return setForegroundWindow(hwnd); + }, + forceForeground(hwnd) { + const currentThreadId = Number(getCurrentThreadId()) || 0; + const foregroundWindow = getForegroundWindow(); + const foregroundThreadId = foregroundWindow == null + ? 0 + : identityForWindow(foregroundWindow).threadId; + const targetThreadId = identityForWindow(hwnd).threadId; + const attached = []; + const attach = (threadId) => { + if ( + threadId > 0 && + threadId !== currentThreadId && + !attached.includes(threadId) && + attachThreadInput(currentThreadId, threadId, true) + ) { + attached.push(threadId); + } + }; + try { + attach(foregroundThreadId); + attach(targetThreadId); + bringWindowToTop(hwnd); + setForegroundWindow(hwnd); + setFocus(hwnd); + } finally { + for (const threadId of attached.reverse()) { + attachThreadInput(currentThreadId, threadId, false); + } + } + }, + flash(hwnd) { + const info = { + cbSize: koffi.sizeof(FLASHWINFO), + hwnd, + dwFlags: FLASHW_TRAY | FLASHW_TIMERNOFG, + uCount: 3, + dwTimeout: 0 + }; + flashWindowEx(info); + } + }; +} + +async function defaultWindowsApi() { + if (!windowsApiPromise) windowsApiPromise = createWindowsApi(); + try { + return await windowsApiPromise; + } catch (error) { + windowsApiPromise = null; + throw error; + } +} + +export async function initializeWindowsFocusRuntime() { + await defaultWindowsApi(); + return true; +} + +function validCachedWindow(api, processId) { + const hwnd = cachedWindows.get(processId); + if ( + hwnd != null && + api.isWindow(hwnd) && + api.processIdForWindow(hwnd) === processId + ) { + return hwnd; + } + cachedWindows.delete(processId); + return null; +} + +export async function activateWindowsProcess(processId, { + api, + wait = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)) +} = {}) { + if (!Number.isInteger(processId) || processId <= 0) { + throw new Error("The connected Codex process identity is unavailable"); + } + const native = api ?? await defaultWindowsApi(); + const hwnd = validCachedWindow(native, processId) ?? native.findWindowForProcess(processId); + if (hwnd == null) throw new Error("The connected Codex Desktop window was not found"); + cachedWindows.set(processId, hwnd); + + const alreadyForeground = sameWindow(native.getForegroundWindow(), hwnd); + const alreadyMaximized = Boolean(native.isZoomed(hwnd)); + if (!alreadyMaximized && !native.maximize(hwnd)) { + throw new Error("Windows did not accept the Codex maximize request"); + } + if (!alreadyForeground) native.foreground(hwnd); + if (!alreadyMaximized) await wait(20); + + let foreground = sameWindow(native.getForegroundWindow(), hwnd); + if (!foreground && !alreadyForeground) { + await wait(25); + foreground = sameWindow(native.getForegroundWindow(), hwnd); + } + if (!foreground && !alreadyForeground && typeof native.forceForeground === "function") { + native.forceForeground(hwnd); + await wait(25); + foreground = sameWindow(native.getForegroundWindow(), hwnd); + } + if (!foreground) { + try { native.flash(hwnd); } catch {} + throw new Error("Windows did not allow Codex Desktop to receive focus"); + } + return { + processId, + alreadyForeground, + alreadyMaximized, + maximized: alreadyMaximized || Boolean(native.isZoomed(hwnd)) + }; +} + +export function clearWindowsFocusCache() { + cachedWindows.clear(); +} diff --git a/test/bridge-installer_test.mjs b/test/bridge-installer_test.mjs index afae92a..4880a9b 100644 --- a/test/bridge-installer_test.mjs +++ b/test/bridge-installer_test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { access, chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -21,6 +21,10 @@ const pluginRoot = fileURLToPath(new URL( )); const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); const execFileAsync = promisify(execFile); +const bundledNativeRuntimeHash = JSON.parse(await readFile( + join(pluginRoot, "installer", "native-runtime", "native-runtime.json"), + "utf8" +)).runtimeHash; async function sha256(path) { return createHash("sha256").update(await readFile(path)).digest("hex"); @@ -222,6 +226,7 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr let serviceOnline = false; let runningVersion = "9.8.7"; let runningRuntimeHash = bundledRuntimeHash; + let runningNativeRuntimeHash = bundledNativeRuntimeHash; const execute = async (command, args, options) => { commands.push([command, args, options]); if (args[0] === "--version") return { stdout: "v22.14.0\n", stderr: "" }; @@ -247,6 +252,7 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr serviceOnline = true; runningVersion = "9.8.7"; runningRuntimeHash = bundledRuntimeHash; + runningNativeRuntimeHash = bundledNativeRuntimeHash; } return { pid: 1000 + children.length, unref() {} }; }; @@ -270,6 +276,7 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr ok: true, bridgeVersion: runningVersion, runtimeHash: runningRuntimeHash, + nativeRuntimeHash: runningNativeRuntimeHash, codexConnected: false }; } @@ -300,6 +307,7 @@ test("Windows installer uses LocalAppData, a capability token, and user-level pr assert.equal(await readFile(join(dataRoot, "bridge.pid"), "utf8"), "1001\n"); const metadata = JSON.parse(await readFile(join(dataRoot, "install.json"), "utf8")); assert.equal(metadata.runtimeHash, bundledRuntimeHash); + assert.equal(metadata.nativeRuntimeHash, bundledNativeRuntimeHash); runningVersion = "0.5.0"; runningRuntimeHash = "stale-runtime"; @@ -359,6 +367,7 @@ test("a failed Windows Bridge restart restores the previous runtime and metadata let serviceOnline = true; let runningVersion = "1.0.0"; let runningRuntimeHash = oldRuntimeHash; + let runningNativeRuntimeHash = null; let bridgeStarts = 0; await mkdir(join(home, "bin"), { recursive: true }); @@ -402,9 +411,11 @@ test("a failed Windows Bridge restart restores the previous runtime and metadata if (bridgeStarts === 1) { runningVersion = "9.8.7"; runningRuntimeHash = "unexpected-runtime"; + runningNativeRuntimeHash = bundledNativeRuntimeHash; } else { runningVersion = "1.0.0"; runningRuntimeHash = oldRuntimeHash; + runningNativeRuntimeHash = null; } } return { pid: 2000 + bridgeStarts, unref() {} }; @@ -418,6 +429,7 @@ test("a failed Windows Bridge restart restores the previous runtime and metadata ok: true, bridgeVersion: runningVersion, runtimeHash: runningRuntimeHash, + nativeRuntimeHash: runningNativeRuntimeHash, codexConnected: false }; } @@ -455,6 +467,7 @@ test("separate installer instances serialize the same Windows Bridge update", as let serviceOnline = true; let runningVersion = "1.0.0"; let runningRuntimeHash = oldRuntimeHash; + let runningNativeRuntimeHash = null; let bridgeStarts = 0; await mkdir(join(home, "bin"), { recursive: true }); @@ -495,6 +508,7 @@ test("separate installer instances serialize the same Windows Bridge update", as serviceOnline = true; runningVersion = "9.8.7"; runningRuntimeHash = bundledRuntimeHash; + runningNativeRuntimeHash = bundledNativeRuntimeHash; return { pid: 3000 + bridgeStarts, unref() {} }; }, fetchImpl: async url => { @@ -506,6 +520,7 @@ test("separate installer instances serialize the same Windows Bridge update", as ok: true, bridgeVersion: runningVersion, runtimeHash: runningRuntimeHash, + nativeRuntimeHash: runningNativeRuntimeHash, codexConnected: false }; } @@ -549,15 +564,17 @@ test("an interrupted Windows update restores both backups when the new runtime c let serviceOnline = false; let runningVersion = null; let runningRuntimeHash = null; + let runningNativeRuntimeHash = null; let bridgeStarts = 0; await mkdir(join(home, "bin"), { recursive: true }); await mkdir(dataRoot, { recursive: true }); await writeFile(systemNode, "fake node"); const resolvedNode = await realpath(systemNode); - const metadata = (version, runtimeHash) => ({ + const metadata = (version, runtimeHash, nativeRuntimeHash = null) => ({ version, runtimeHash, + nativeRuntimeHash, platform: "win32", codexChannel: "stable", nodeExecutable: resolvedNode, @@ -566,8 +583,16 @@ test("an interrupted Windows update restores both backups when the new runtime c installedAt: "2026-01-01T00:00:00.000Z" }); const oldMetadata = metadata("1.0.0", oldRuntimeHash); + await cp( + join(pluginRoot, "installer", "native-runtime"), + join(dataRoot, "native-runtimes", bundledNativeRuntimeHash), + { recursive: true } + ); await writeFile(bridgeRuntime, bundledRuntime); - await writeFile(installMetadata, `${JSON.stringify(metadata("9.8.7", bundledRuntimeHash), null, 2)}\n`); + await writeFile( + installMetadata, + `${JSON.stringify(metadata("9.8.7", bundledRuntimeHash, bundledNativeRuntimeHash), null, 2)}\n` + ); await writeFile(join(dataRoot, ".bridge.mjs.previous"), oldRuntime); await writeFile(join(dataRoot, ".install.json.previous"), `${JSON.stringify(oldMetadata, null, 2)}\n`); await writeFile(join(dataRoot, "bridge-token"), "synthetic-test-token\n"); @@ -594,12 +619,15 @@ test("an interrupted Windows update restores both backups when the new runtime c if (bridgeStarts === 1) { runningVersion = "9.8.7"; runningRuntimeHash = "failed-new-runtime"; + runningNativeRuntimeHash = bundledNativeRuntimeHash; } else if (bridgeStarts === 2) { runningVersion = "1.0.0"; runningRuntimeHash = oldRuntimeHash; + runningNativeRuntimeHash = null; } else { runningVersion = "9.8.7"; runningRuntimeHash = bundledRuntimeHash; + runningNativeRuntimeHash = bundledNativeRuntimeHash; } return { pid: 4000 + bridgeStarts, unref() {} }; }, @@ -612,6 +640,7 @@ test("an interrupted Windows update restores both backups when the new runtime c ok: true, bridgeVersion: runningVersion, runtimeHash: runningRuntimeHash, + nativeRuntimeHash: runningNativeRuntimeHash, codexConnected: false }; } diff --git a/test/bridge_test.mjs b/test/bridge_test.mjs index aae926c..7bb8a45 100644 --- a/test/bridge_test.mjs +++ b/test/bridge_test.mjs @@ -16,10 +16,18 @@ import { } from "../src/bridge/codex-cdp.mjs"; import { debugPortsFromCommandLines, + debugProcessesFromCommandLines, + discoverDebugEndpoint, discoverDebugPort, discoverWindowsCodexExecutables, focusCodex } from "../src/bridge/platform.mjs"; +import { navigateAndFocus } from "../src/bridge/navigation.mjs"; +import { + activateWindowsProcess, + clearWindowsFocusCache, + initializeWindowsFocusRuntime +} from "../src/bridge/windows-focus.mjs"; import { bridgeRequestAuthorized } from "../src/bridge/auth.mjs"; const UUID = "f6805b8a-332a-43a0-a118-52d3e59542f6"; @@ -51,7 +59,7 @@ test("Bridge write authorization fails closed and accepts only the installed tok assert.equal(bridgeRequestAuthorized(token, `Bearer ${token}`), true); }); -test("CDP discovery probes the fixed loopback port before platform process inspection", async () => { +test("CDP discovery still probes the fixed loopback port when process identity inspection is denied", async () => { const requests = []; let processInspections = 0; const port = await discoverDebugPort({ @@ -64,7 +72,7 @@ test("CDP discovery probes the fixed loopback port before platform process inspe }); assert.equal(port, 9222); assert.deepEqual(requests, ["http://127.0.0.1:9222/json/version"]); - assert.equal(processInspections, 0); + assert.equal(processInspections, 1); }); test("Windows CDP and Appx discovery accept Stable and Beta process shapes", async () => { @@ -72,6 +80,58 @@ test("Windows CDP and Appx discovery accept Stable and Beta process shapes", asy '"ChatGPT.exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222', '"ChatGPT (Beta).exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port 9333' ].join("\n")), [9222, 9333]); + const processRows = JSON.stringify([ + { + processId: 4101, + executable: "C:\\WindowsApps\\OpenAI.Codex_Stable\\app\\ChatGPT.exe", + commandLine: '"ChatGPT.exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222' + }, + { + processId: 4202, + executable: "C:\\WindowsApps\\OpenAI.CodexBeta_Beta\\app\\ChatGPT (Beta).exe", + commandLine: '"ChatGPT (Beta).exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9333' + } + ]); + assert.deepEqual( + debugProcessesFromCommandLines(processRows).map(({ port, processId, channel }) => ({ port, processId, channel })), + [ + { port: 9222, processId: 4101, channel: "stable" }, + { port: 9333, processId: 4202, channel: "beta" } + ] + ); + const selectedBeta = await discoverDebugEndpoint({ + platform: "win32", + preferredPort: 9333, + execute: async () => ({ stdout: processRows }), + fetchImpl: async (url) => ({ ok: url.includes(":9333/"), json: async () => ({ Browser: "Chrome" }) }) + }); + assert.deepEqual(selectedBeta, { + port: 9333, + processId: 4202, + executable: "C:\\WindowsApps\\OpenAI.CodexBeta_Beta\\app\\ChatGPT (Beta).exe", + channel: "beta" + }); + const contestedPortRows = JSON.stringify([ + { + processId: 4101, + executable: "C:\\WindowsApps\\OpenAI.Codex_Stable\\app\\ChatGPT.exe", + commandLine: '"ChatGPT.exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222', + ownsDebugPort: false + }, + { + processId: 4202, + executable: "C:\\WindowsApps\\OpenAI.CodexBeta_Beta\\app\\ChatGPT (Beta).exe", + commandLine: '"ChatGPT (Beta).exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222', + ownsDebugPort: true + } + ]); + const selectedListener = await discoverDebugEndpoint({ + platform: "win32", + execute: async () => ({ stdout: contestedPortRows }), + fetchImpl: async () => ({ ok: true, json: async () => ({ Browser: "Chrome" }) }) + }); + assert.equal(selectedListener.processId, 4202); + assert.equal(selectedListener.channel, "beta"); let appxOptions; const installs = await discoverWindowsCodexExecutables({ execute: async (_command, _args, options) => { @@ -88,66 +148,80 @@ test("Windows CDP and Appx discovery accept Stable and Beta process shapes", asy assert.equal(appxOptions.windowsHide, true); }); -test("Windows background PowerShell calls stay hidden and maximize on fallback focus", async () => { +test("Windows native focus maximizes and activates only the connected Stable or Beta process", async () => { + clearWindowsFocusCache(); + const windows = new Map([[4202, 0xBEEFn]]); + let foreground = 0xAAAAn; + let maximized = false; const calls = []; - const execute = async (command, args, options) => { - calls.push({ command, args, options }); - if (args.join(" ").includes("CODEX_BRIDGE_FOCUS_PID")) { - return { stdout: "4242\n" }; - } - return { - stdout: '\"ChatGPT.exe\" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9333\n' - }; + const api = { + isWindow: hwnd => hwnd === 0xBEEFn, + processIdForWindow: hwnd => hwnd === 0xBEEFn ? 4202 : 0, + findWindowForProcess: processId => windows.get(processId) ?? null, + getForegroundWindow: () => foreground, + isZoomed: () => maximized, + maximize: hwnd => { calls.push(["maximize", hwnd]); maximized = true; return true; }, + foreground: hwnd => { calls.push(["foreground", hwnd]); foreground = hwnd; return true; }, + flash: hwnd => calls.push(["flash", hwnd]) }; - await discoverDebugPort({ + const result = await activateWindowsProcess(4202, { api, wait: async () => {} }); + assert.equal(result.processId, 4202); + assert.equal(result.maximized, true); + assert.deepEqual(calls, [["maximize", 0xBEEFn], ["foreground", 0xBEEFn]]); + + let powershellCalls = 0; + const focused = await focusCodex({ platform: "win32", - execute, - fetchImpl: async (url) => { - if (url.includes(":9333/")) return { ok: true, json: async () => ({ Browser: "Chrome" }) }; - throw new Error("offline fixture"); - } + processId: 4202, + execute: async () => { powershellCalls += 1; }, + activateWindows: async processId => ({ processId, alreadyForeground: true, alreadyMaximized: true }) }); - await focusCodex({ platform: "win32", execute }); - await focusCodex({ platform: "win32", execute }); - assert.equal(calls.length, 3); - assert.ok(calls.every(({ command, args, options }) => - command === "powershell.exe" && - options?.windowsHide === true && - args.includes("-WindowStyle") && - args.includes("Hidden") - )); - const focusCalls = calls.slice(1); - assert.ok(focusCalls.every(({ args, options }) => - args.join(" ").includes("ShowWindowAsync($candidate.MainWindowHandle, 3)") && - args.join(" ").includes("SetForegroundWindow") && - options.timeout === 5000 - )); - assert.equal(focusCalls[1].options.env.CODEX_BRIDGE_FOCUS_PID, "4242"); + assert.equal(focused.processId, 4202); + assert.equal(powershellCalls, 0, "the key hot path must not spawn PowerShell"); }); -test("CDP focus maximizes the Codex window before bringing it to the foreground", async () => { - const client = new CodexCdpClient(); +test("Windows native focus runtime loads the User32 bindings", { + skip: process.platform !== "win32" +}, async () => { + assert.equal(await initializeWindowsFocusRuntime(), true); +}); + +test("Windows native focus uses the bounded attached-input fallback only after foreground denial", async () => { + clearWindowsFocusCache(); + let foreground = 0x1111n; const calls = []; - const trace = createTraceFixture(); - client.connect = async () => {}; - client.sendCommand = async (method, params) => { - calls.push({ method, params }); - return method === "Browser.getWindowForTarget" ? { windowId: 42 } : {}; + const api = { + isWindow: () => true, + processIdForWindow: () => 4203, + findWindowForProcess: () => 0x2222n, + getForegroundWindow: () => foreground, + isZoomed: () => true, + foreground: hwnd => { calls.push(["foreground", hwnd]); return false; }, + forceForeground: hwnd => { calls.push(["forceForeground", hwnd]); foreground = hwnd; }, + flash: hwnd => calls.push(["flash", hwnd]) }; + const waits = []; + const result = await activateWindowsProcess(4203, { + api, + wait: async milliseconds => waits.push(milliseconds) + }); + assert.equal(result.alreadyMaximized, true); + assert.deepEqual(calls, [["foreground", 0x2222n], ["forceForeground", 0x2222n]]); + assert.deepEqual(waits, [25, 25]); +}); - await client.focusWindow(trace); - - assert.deepEqual(calls, [ - { method: "Browser.getWindowForTarget", params: {} }, - { - method: "Browser.setWindowBounds", - params: { windowId: 42, bounds: { windowState: "maximized" } } - }, - { method: "Page.bringToFront", params: {} } - ]); - assert.deepEqual( - trace.events.filter(event => event.outcome === "succeeded").map(event => event.stage), - ["focus.connect", "focus.get-window", "focus.maximize", "focus.bring-to-front"] +test("Task navigation success is not converted into failure when native focus is denied", async () => { + const result = await navigateAndFocus( + async () => "navigated", + async () => { throw new Error("foreground denied"); } + ); + assert.deepEqual(result, { focusOk: false }); + await assert.rejects( + navigateAndFocus( + async () => { throw new Error("navigation failed"); }, + async () => true + ), + /navigation failed/ ); }); @@ -299,6 +373,109 @@ test("model presets freeze the requested model and effort", () => { assert.throws(() => modelPreset("unknown"), /Unknown Codex model preset/); }); +test("model renderer actions are serialized before they touch the shared menu", async () => { + const client = new CodexCdpClient(); + const calls = []; + let active = 0; + let maxActive = 0; + let releaseFirst; + let markFirstStarted; + const firstStarted = new Promise(resolve => { markFirstStarted = resolve; }); + const firstGate = new Promise(resolve => { releaseFirst = resolve; }); + client.connect = async () => {}; + client.dispatchModelPreset = async (action) => { + calls.push(action); + active += 1; + maxActive = Math.max(maxActive, active); + if (calls.length === 1) { + markFirstStarted(); + await firstGate; + } + active -= 1; + }; + + const first = client.dispatchRendererAction("model-sol-high"); + await firstStarted; + const second = client.dispatchRendererAction("model-sol-medium"); + await Promise.resolve(); + + assert.deepEqual(calls, ["model-sol-high"]); + assert.equal(maxActive, 1); + releaseFirst(); + await Promise.all([first, second]); + assert.deepEqual(calls, ["model-sol-high", "model-sol-medium"]); + assert.equal(maxActive, 1); +}); + +test("model presets identify picker rows once per renderer connection", async () => { + const client = new CodexCdpClient(); + const clickStages = []; + const effortOrder = ["low", "medium", "high", "xhigh", "max"]; + let currentModel = "5.6 Sol"; + let currentEffort = "medium"; + let expanded = false; + + client.evaluate = async (expression) => { + if (expression.includes('const triggers = [...document.querySelectorAll("[data-codex-intelligence-trigger]")].filter(visible)')) { + return { text: currentModel, effort: currentEffort, expanded }; + } + if (expression.includes("rowCount: menus[0]?.querySelectorAll")) { + return { count: 0, expanded: true, rowCount: 2 }; + } + if (expression.includes("return menu?.querySelectorAll") && expression.includes("length ?? 0")) { + return 2; + } + throw new Error(`Unexpected model fixture expression: ${expression.slice(0, 80)}`); + }; + client.waitForRenderer = async (_expression, description, _trace, stage) => { + if (description === "Codex model picker submenu") { + if (stage === "model.wait-submenu-1") { + return ["5.6 Sol", "5.6 Luna"].map(text => ({ text, checked: text === currentModel })); + } + return effortOrder.map(text => ({ text, checked: text === currentEffort })); + } + return true; + }; + client.clickRendererCandidates = async (_expression, description, _trace, stage) => { + clickStages.push(stage); + if (stage === "model.open-trigger") expanded = true; + if (stage === "model.select-model-option") { + currentModel = description.includes("Luna") ? "5.6 Luna" : "5.6 Sol"; + } + if (stage === "model.select-effort-option") { + currentEffort = description.split(" ").at(-1); + } + }; + client.pressRendererEscape = async (_trace, stage) => { + clickStages.push(stage); + expanded = false; + }; + + const firstTrace = createTraceFixture(); + await client.dispatchModelPreset("model-luna-max", firstTrace); + assert.equal(clickStages.filter(stage => stage === "model.open-row-1").length, 1); + assert.equal(clickStages.filter(stage => stage === "model.open-row-2").length, 1); + const selectionStages = firstTrace.events + .filter(event => event.event === "cdp.stage" && event.outcome === "started") + .map(event => event.stage); + assert.ok(selectionStages.indexOf("model.select-model") < selectionStages.indexOf("model.select-effort")); + assert.equal(selectionStages.includes("model.select-effort-1"), false); + assert.equal(selectionStages.includes("model.select-effort-2"), false); + + const cachedTrace = createTraceFixture(); + await client.dispatchModelPreset("model-sol-high", cachedTrace); + assert.equal(clickStages.filter(stage => stage === "model.open-row-1").length, 1); + assert.equal(clickStages.filter(stage => stage === "model.open-row-2").length, 1); + assert.ok(cachedTrace.events.some(event => + event.event === "model.rows-identified" && event.source === "cache" + )); + + client.disconnect(); + await client.dispatchModelPreset("model-luna-max", createTraceFixture()); + assert.equal(clickStages.filter(stage => stage === "model.open-row-1").length, 2); + assert.equal(clickStages.filter(stage => stage === "model.open-row-2").length, 2); +}); + test("new renderer action accepts the current localized New conversation control", () => { let clicks = 0; const button = { diff --git a/test/windows-native-runtime_test.mjs b/test/windows-native-runtime_test.mjs new file mode 100644 index 0000000..55b8505 --- /dev/null +++ b/test/windows-native-runtime_test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const nativeRoot = fileURLToPath(new URL( + "../integration/com.ulanzi.codexmicro.ulanziPlugin/installer/native-runtime/", + import.meta.url +)); + +test("the self-contained Windows native focus runtime resolves outside repository dependencies", { + skip: process.platform !== "win32" +}, async () => { + process.env.CODEX_BRIDGE_NATIVE_ROOT = nativeRoot; + try { + const { initializeWindowsFocusRuntime } = await import("../src/bridge/windows-focus.mjs"); + assert.equal(await initializeWindowsFocusRuntime(), true); + } finally { + delete process.env.CODEX_BRIDGE_NATIVE_ROOT; + } +}); From ccefce7e9337e2384f8010e7ee97ce3937e98d54 Mon Sep 17 00:00:00 2001 From: yyf <2358929807@qq.com> Date: Fri, 21 Aug 2026 13:04:52 +0800 Subject: [PATCH 8/8] build: make the Windows runtime self-contained --- docs/setup-and-operations.md | 2 +- .../build.mjs | 3 +- .../dist/app.js | 1397 ++++++++++++++--- .../installer/bridge.mjs | 1241 ++++++++++++++- .../manifest.json | 2 +- package-lock.json | 270 ++++ package.json | 4 +- 7 files changed, 2639 insertions(+), 280 deletions(-) diff --git a/docs/setup-and-operations.md b/docs/setup-and-operations.md index 66a7665..7e2263b 100644 --- a/docs/setup-and-operations.md +++ b/docs/setup-and-operations.md @@ -49,7 +49,7 @@ macOS keeps the wrapper and LaunchAgent flow: ## Install from the repository -From `plugins/codex-console`: +From the `OpenCodexMicro` repository root: ```text npm install diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs index 1aa625e..48603a2 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs @@ -5,7 +5,6 @@ import { resolve } from "node:path"; const packageRoot = resolve("."); const repositoryRoot = resolve(packageRoot, "../.."); -const workspaceRoot = resolve(repositoryRoot, "../.."); const installerRoot = resolve(packageRoot, "installer"); const nativeRuntimeRoot = resolve(installerRoot, "native-runtime"); const manifest = JSON.parse(await readFile(resolve(packageRoot, "manifest.json"), "utf8")); @@ -53,7 +52,7 @@ const nativeRuntimeFiles = [ await rm(nativeRuntimeRoot, { recursive: true, force: true }); const manifestFiles = []; for (const [sourceRelative, destinationRelative] of nativeRuntimeFiles) { - const source = resolve(workspaceRoot, "node_modules", sourceRelative); + const source = resolve(repositoryRoot, "node_modules", sourceRelative); const destination = resolve(nativeRuntimeRoot, destinationRelative); await mkdir(resolve(destination, ".."), { recursive: true }); await copyFile(source, destination); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js index 4e698d7..63e6cb9 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/dist/app.js @@ -301,7 +301,7 @@ var require_permessage_deflate = __commonJS({ acceptAsServer(offers) { const opts = this._options; const accepted = offers.find((params) => { - if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && (typeof params.client_max_window_bits === "number" ? opts.clientMaxWindowBits > params.client_max_window_bits : !params.client_max_window_bits)) { + if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) { return false; } return true; @@ -2263,7 +2263,7 @@ var require_websocket = __commonJS({ var http = require("http"); var net = require("net"); var tls = require("tls"); - var { randomBytes, createHash } = require("crypto"); + var { randomBytes: randomBytes2, createHash: createHash2 } = require("crypto"); var { Duplex, Readable } = require("stream"); var { URL } = require("url"); var PerMessageDeflate2 = require_permessage_deflate(); @@ -2801,7 +2801,7 @@ var require_websocket = __commonJS({ } } const defaultPort = isSecure ? 443 : 80; - const key = randomBytes(16).toString("base64"); + const key = randomBytes2(16).toString("base64"); const request = isSecure ? https.request : http.request; const protocolSet = /* @__PURE__ */ new Set(); let perMessageDeflate; @@ -2931,7 +2931,7 @@ var require_websocket = __commonJS({ abortHandshake(websocket, socket2, "Invalid Upgrade header"); return; } - const digest = createHash("sha1").update(key + GUID).digest("base64"); + const digest = createHash2("sha1").update(key + GUID).digest("base64"); if (res.headers["sec-websocket-accept"] !== digest) { abortHandshake(websocket, socket2, "Invalid Sec-WebSocket-Accept header"); return; @@ -3300,7 +3300,7 @@ var require_websocket_server = __commonJS({ var EventEmitter = require("events"); var http = require("http"); var { Duplex } = require("stream"); - var { createHash } = require("crypto"); + var { createHash: createHash2 } = require("crypto"); var extension2 = require_extension(); var PerMessageDeflate2 = require_permessage_deflate(); var subprotocol2 = require_subprotocol(); @@ -3607,7 +3607,7 @@ var require_websocket_server = __commonJS({ ); } if (this._state > RUNNING) return abortHandshake(socket2, 503); - const digest = createHash("sha1").update(key + GUID).digest("base64"); + const digest = createHash2("sha1").update(key + GUID).digest("base64"); const headers = [ "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", @@ -3706,17 +3706,224 @@ var import_websocket_server = __toESM(require_websocket_server(), 1); var wrapper_default = import_websocket.default; // plugin/app.js +var import_node_crypto2 = require("node:crypto"); var import_node_fs2 = require("node:fs"); var import_node_path2 = require("node:path"); // plugin/bridge-installer.js var import_node_fs = require("node:fs"); var import_promises = require("node:fs/promises"); -var import_node_child_process = require("node:child_process"); -var import_node_util = require("node:util"); +var import_node_child_process2 = require("node:child_process"); +var import_node_crypto = require("node:crypto"); +var import_node_util2 = require("node:util"); var import_node_os = require("node:os"); var import_node_path = require("node:path"); + +// ../../src/bridge/platform.mjs +var import_node_child_process = require("node:child_process"); +var import_node_util = require("node:util"); var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile); +var CDP_HOST = "127.0.0.1"; +var DEFAULT_CDP_PORT = 9222; +var CDP_ARGUMENTS = Object.freeze([ + `--remote-debugging-address=${CDP_HOST}`, + `--remote-debugging-port=${DEFAULT_CDP_PORT}`, + `--remote-allow-origins=http://${CDP_HOST}:${DEFAULT_CDP_PORT}` +]); +var WINDOWS_PROCESS_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$listenerOwners = @{} +Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalAddress -eq '127.0.0.1' } | + ForEach-Object { $listenerOwners[('{0}:{1}' -f $_.LocalPort, $_.OwningProcess)] = $true } +$rows = Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' } | + ForEach-Object { + $portMatch = [regex]::Match([string]$_.CommandLine, '--remote-debugging-port(?:=|\s+)(\d+)') + $debugPort = if ($portMatch.Success) { [int]$portMatch.Groups[1].Value } else { 0 } + [pscustomobject]@{ + processId = [int]$_.ProcessId + executable = [string]$_.ExecutablePath + commandLine = [string]$_.CommandLine + ownsDebugPort = $debugPort -gt 0 -and $listenerOwners.ContainsKey(('{0}:{1}' -f $debugPort, $_.ProcessId)) + } + } +@($rows) | ConvertTo-Json -Compress +`; +var WINDOWS_PACKAGE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$rows = foreach ($name in @('OpenAI.Codex', 'OpenAI.CodexBeta')) { + $package = Get-AppxPackage -Name $name | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $package) { continue } + $app = Join-Path $package.InstallLocation 'app' + $names = if ($name -eq 'OpenAI.CodexBeta') { + @('ChatGPT (Beta).exe', 'Codex (Beta).exe', 'ChatGPT.exe') + } else { + @('ChatGPT.exe', 'Codex.exe') + } + foreach ($file in $names) { + $candidate = Join-Path $app $file + if (Test-Path -LiteralPath $candidate) { + [pscustomobject]@{ + channel = if ($name -eq 'OpenAI.CodexBeta') { 'beta' } else { 'stable' } + packageName = $name + packageFullName = $package.PackageFullName + executable = $candidate + } + break + } + } +} +@($rows) | ConvertTo-Json -Compress +`; +var WINDOWS_STOP_EXECUTABLE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$target = $env:CODEX_BRIDGE_TARGET_EXECUTABLE +$processes = @(Get-CimInstance Win32_Process | + Where-Object { + $_.ExecutablePath -eq $target -and + $_.CommandLine -notlike '*--type=*' -and + $_.CommandLine -notlike '*crashpad-handler*' + }) +foreach ($process in $processes) { Stop-Process -Id $process.ProcessId -ErrorAction Stop } +foreach ($process in $processes) { Wait-Process -Id $process.ProcessId -Timeout 8 -ErrorAction SilentlyContinue } +`; +function powershellArgs(command, extra = []) { + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + command, + ...extra + ]; +} +function powershellOptions(options = {}) { + return { ...options, windowsHide: true }; +} +function processChannel(executable, commandLine) { + const identity = `${executable || ""} ${commandLine || ""}`.toLowerCase(); + return /codexbeta|chatgpt\s*\(beta\)|codex\s*\(beta\)/.test(identity) ? "beta" : "stable"; +} +function debugProcessesFromCommandLines(text) { + const source = String(text || "").trim(); + let rows = []; + if (source.startsWith("[") || source.startsWith("{")) { + try { + const parsed = JSON.parse(source); + rows = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + } + } + if (rows.length === 0) { + rows = source.split(/\r?\n/).filter(Boolean).map((commandLine) => ({ commandLine })); + } + return rows.flatMap((row) => { + const commandLine = String(row?.commandLine || ""); + if (!commandLine.includes("--remote-debugging-address=127.0.0.1")) return []; + if (commandLine.includes("--type=")) return []; + const port2 = Number(commandLine.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); + if (!Number.isInteger(port2) || port2 <= 0 || port2 > 65535) return []; + const processId = Number(row?.processId); + const executable = typeof row?.executable === "string" ? row.executable : null; + return [{ + port: port2, + processId: Number.isInteger(processId) && processId > 0 ? processId : null, + executable, + channel: processChannel(executable, commandLine), + ownsDebugPort: row?.ownsDebugPort === true + }]; + }); +} +async function fetchJson(url, timeout = 1200, fetchImpl = fetch) { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); + return response.json(); +} +async function processCommandLines(platform, execute) { + if (platform === "win32") { + return (await execute( + "powershell.exe", + powershellArgs(WINDOWS_PROCESS_COMMAND), + powershellOptions({ timeout: 4e3 }) + )).stdout; + } + if (platform === "darwin") { + return (await execute("/bin/ps", ["-axo", "command="], { timeout: 4e3 })).stdout; + } + return ""; +} +async function discoverDebugEndpoint({ + platform = process.platform, + execute = execFileAsync, + fetchImpl = fetch, + preferredPort = DEFAULT_CDP_PORT +} = {}) { + let processes = []; + try { + processes = debugProcessesFromCommandLines(await processCommandLines(platform, execute)); + } catch { + } + const candidates = [preferredPort, ...processes.map((item) => item.port)]; + for (const port2 of [...new Set(candidates)]) { + try { + await fetchJson(`http://${CDP_HOST}:${port2}/json/version`, 500, fetchImpl); + const process2 = processes.find((item) => item.port === port2 && item.ownsDebugPort) ?? processes.find((item) => item.port === port2); + return { + port: port2, + processId: process2?.processId ?? null, + executable: process2?.executable ?? null, + channel: process2?.channel ?? null + }; + } catch { + } + } + throw new Error("Codex is not running with the local debug bridge"); +} +async function discoverWindowsCodexExecutables({ execute = execFileAsync } = {}) { + const { stdout = "" } = await execute( + "powershell.exe", + powershellArgs(WINDOWS_PACKAGE_COMMAND), + powershellOptions({ timeout: 8e3 }) + ); + if (!String(stdout).trim()) return []; + const parsed = JSON.parse(String(stdout)); + const rows = Array.isArray(parsed) ? parsed : [parsed]; + return rows.filter( + (row) => ["stable", "beta"].includes(row?.channel) && typeof row?.executable === "string" && row.executable.toLowerCase().endsWith(".exe") + ); +} +async function launchWindowsCodex({ + channel = "stable", + execute = execFileAsync, + spawnProcess = import_node_child_process.spawn +} = {}) { + const installations = await discoverWindowsCodexExecutables({ execute }); + const selected = installations.find((item) => item.channel === channel) ?? installations.find((item) => item.channel === "stable") ?? installations[0]; + if (!selected) { + throw new Error("Codex Desktop Stable or Beta was not found in the current Windows account."); + } + await execute( + "powershell.exe", + powershellArgs(WINDOWS_STOP_EXECUTABLE_COMMAND), + powershellOptions({ + timeout: 1e4, + env: { ...process.env, CODEX_BRIDGE_TARGET_EXECUTABLE: selected.executable } + }) + ); + const child = spawnProcess(selected.executable, [...CDP_ARGUMENTS], { + detached: true, + stdio: "ignore", + windowsHide: false + }); + child.unref?.(); + return selected; +} + +// plugin/bridge-installer.js +var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile); function xml(value) { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } @@ -3735,6 +3942,84 @@ async function readJson(path) { return null; } } +async function fileSha256(path) { + try { + return (0, import_node_crypto.createHash)("sha256").update(await (0, import_promises.readFile)(path)).digest("hex"); + } catch { + return null; + } +} +async function verifiedNativeRuntimeHash(root) { + const manifest = await readJson((0, import_node_path.join)(root, "native-runtime.json")); + if (manifest?.version !== 1 || !/^[a-f0-9]{64}$/.test(String(manifest?.runtimeHash || "")) || !Array.isArray(manifest?.files)) { + return null; + } + const verified = []; + for (const file of manifest.files) { + const relative = String(file?.path || "").replaceAll("\\", "/"); + if (!relative || relative.startsWith("/") || relative.split("/").includes("..")) return null; + const sha256 = await fileSha256((0, import_node_path.join)(root, ...relative.split("/"))); + if (!sha256 || sha256 !== file.sha256) return null; + verified.push({ path: relative, sha256 }); + } + const computed = (0, import_node_crypto.createHash)("sha256").update(verified.map((file) => `${file.path}:${file.sha256}`).join("\n")).digest("hex"); + return computed === manifest.runtimeHash ? computed : null; +} +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} +async function acquireFilesystemLock({ + lockPath, + timeoutMs = 15e3, + staleMs = 3e5, + wait = (milliseconds) => new Promise((resolve3) => setTimeout(resolve3, milliseconds)) +}) { + const ownerToken = (0, import_node_crypto.randomBytes)(24).toString("base64url"); + await (0, import_promises.mkdir)((0, import_node_path.dirname)(lockPath), { recursive: true }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await (0, import_promises.mkdir)(lockPath, { mode: 448 }); + await (0, import_promises.writeFile)((0, import_node_path.join)(lockPath, "owner.json"), `${JSON.stringify({ + pid: process.pid, + ownerToken, + createdAt: (/* @__PURE__ */ new Date()).toISOString() + })} +`); + return async () => { + const owner = await readJson((0, import_node_path.join)(lockPath, "owner.json")); + if (owner?.ownerToken === ownerToken) { + await (0, import_promises.rm)(lockPath, { recursive: true, force: true }); + } + }; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + const [lockInfo, owner] = await Promise.all([ + (0, import_promises.stat)(lockPath).catch(() => null), + readJson((0, import_node_path.join)(lockPath, "owner.json")) + ]); + if (lockInfo && Date.now() - lockInfo.mtimeMs > staleMs && !processIsAlive(Number(owner?.pid))) { + const abandonedLock = `${lockPath}.abandoned-${process.pid}-${ownerToken}`; + try { + await (0, import_promises.rename)(lockPath, abandonedLock); + await (0, import_promises.rm)(abandonedLock, { recursive: true, force: true }); + } catch (recoveryError) { + if (!await exists(lockPath)) continue; + if (!["ENOENT", "EEXIST", "EPERM", "EACCES"].includes(recoveryError?.code)) throw recoveryError; + } + continue; + } + await wait(100); + } + } + throw new Error("Another lifecycle operation is still running."); +} async function nodeVersion(executable, execute) { try { const { stdout = "" } = await execute(executable, ["--version"]); @@ -3745,12 +4030,22 @@ async function nodeVersion(executable, execute) { return null; } } +function bridgeDataRoot({ + platform = process.platform, + home = (0, import_node_os.homedir)(), + localAppData = process.env.LOCALAPPDATA +} = {}) { + if (platform === "win32") { + return (0, import_node_path.join)(localAppData || (0, import_node_path.join)(home, "AppData", "Local"), "OpenCodexMicro"); + } + return (0, import_node_path.join)(home, "Library", "Application Support", "OpenCodexMicro"); +} async function selectBridgeNodeRuntime({ home = (0, import_node_os.homedir)(), fallbackNodeExecutable = process.execPath, environmentPath = process.env.PATH || "", platform = process.platform, - execute = execFileAsync + execute = execFileAsync2 } = {}) { const candidates = []; if (platform === "darwin" && await exists("/bin/zsh", import_node_fs.constants.X_OK)) { @@ -3761,34 +4056,37 @@ async function selectBridgeNodeRuntime({ } catch { } } + const nodeName = platform === "win32" ? "node.exe" : "node"; for (const directory of environmentPath.split(import_node_path.delimiter).filter(Boolean)) { - candidates.push((0, import_node_path.join)(directory, "node")); + candidates.push((0, import_node_path.join)(directory, nodeName)); + } + if (platform === "darwin") { + candidates.push( + "/opt/homebrew/bin/node", + "/usr/local/bin/node", + "/usr/bin/node", + (0, import_node_path.join)(home, ".local", "bin", "node") + ); } - candidates.push( - "/opt/homebrew/bin/node", - "/usr/local/bin/node", - "/usr/bin/node", - (0, import_node_path.join)(home, ".local", "bin", "node") - ); let resolvedFallback = fallbackNodeExecutable; try { resolvedFallback = await (0, import_promises.realpath)(fallbackNodeExecutable); } catch { } for (const executable of [...new Set(candidates)]) { - if (!executable.startsWith("/")) continue; - if (!await exists(executable, import_node_fs.constants.X_OK)) continue; + if (!(0, import_node_path.isAbsolute)(executable)) continue; + if (!await exists(executable, platform === "win32" ? import_node_fs.constants.F_OK : import_node_fs.constants.X_OK)) continue; const resolvedExecutable = await (0, import_promises.realpath)(executable); if (resolvedExecutable === resolvedFallback) continue; - const version = await nodeVersion(resolvedExecutable, execute); - if (version?.major >= 20) { - return { executable: resolvedExecutable, version: version.text, source: "system" }; + const candidateVersion = await nodeVersion(resolvedExecutable, execute); + if (candidateVersion?.major >= 20) { + return { executable: resolvedExecutable, version: candidateVersion.text, source: "system" }; } } - if (await exists(resolvedFallback, import_node_fs.constants.X_OK)) { - const version = await nodeVersion(resolvedFallback, execute); - if (version?.major >= 20) { - return { executable: resolvedFallback, version: version.text, source: "ulanzi" }; + if (await exists(resolvedFallback, platform === "win32" ? import_node_fs.constants.F_OK : import_node_fs.constants.X_OK)) { + const fallbackVersion = await nodeVersion(resolvedFallback, execute); + if (fallbackVersion?.major >= 20) { + return { executable: resolvedFallback, version: fallbackVersion.text, source: "ulanzi" }; } } throw new Error("No compatible Node.js 20 or later runtime was found."); @@ -3798,13 +4096,31 @@ function createBridgeInstaller({ bridgeUrl, version, home = (0, import_node_os.homedir)(), + localAppData = process.env.LOCALAPPDATA, uid = process.getuid?.(), platform = process.platform, nodeExecutable = process.execPath, environmentPath = process.env.PATH || "", - execute = execFileAsync + codexChannel = process.env.CODEX_DESKTOP_CHANNEL || "stable", + execute = execFileAsync2, + spawnProcess = import_node_child_process2.spawn, + fetchImpl = fetch, + serviceStartTimeoutMs = 8e3, + wait = (milliseconds) => new Promise((resolve3) => setTimeout(resolve3, milliseconds)) }) { - const appRoot = (0, import_node_path.join)(home, "Library", "Application Support", "OpenCodexMicro"); + const appRoot = bridgeDataRoot({ platform, home, localAppData }); + const bridgeRuntime = (0, import_node_path.join)(appRoot, "bridge.mjs"); + const tokenPath = (0, import_node_path.join)(appRoot, "bridge-token"); + const pidPath = (0, import_node_path.join)(appRoot, "bridge.pid"); + const installMetadata = (0, import_node_path.join)(appRoot, "install.json"); + const lifecycleLock = (0, import_node_path.join)((0, import_node_path.dirname)(appRoot), ".OpenCodexMicro.lifecycle.lock"); + const installerRoot = (0, import_node_path.resolve)(pluginRoot, "installer"); + const bundledRuntime = (0, import_node_path.join)(installerRoot, "bridge.mjs"); + const bundledIcon = (0, import_node_path.join)(installerRoot, "CodexBridge.png"); + const bundledNativeRuntime = (0, import_node_path.join)(installerRoot, "native-runtime"); + const nativeRuntimesRoot = (0, import_node_path.join)(appRoot, "native-runtimes"); + const runtimeBackup = (0, import_node_path.join)(appRoot, ".bridge.mjs.previous"); + const metadataBackup = (0, import_node_path.join)(appRoot, ".install.json.previous"); const userApplications = (0, import_node_path.join)(home, "Applications"); const bridgeApp = (0, import_node_path.join)(userApplications, "Codex Bridge.app"); const bridgeContents = (0, import_node_path.join)(bridgeApp, "Contents"); @@ -3813,50 +4129,115 @@ function createBridgeInstaller({ const bridgeLicenses = (0, import_node_path.join)(bridgeResources, "licenses"); const bridgeExecutable = (0, import_node_path.join)(bridgeMacOS, "Codex Bridge"); const bridgeIcon = (0, import_node_path.join)(bridgeResources, "CodexBridge.icns"); - const bridgeRuntime = (0, import_node_path.join)(appRoot, "bridge.mjs"); - const installMetadata = (0, import_node_path.join)(appRoot, "install.json"); const agentsRoot = (0, import_node_path.join)(home, "Library", "LaunchAgents"); const bridgeAgent = (0, import_node_path.join)(agentsRoot, "io.opencodexmicro.bridge.plist"); - const installerRoot = (0, import_node_path.resolve)(pluginRoot, "installer"); - const bundledRuntime = (0, import_node_path.join)(installerRoot, "bridge.mjs"); - const bundledIcon = (0, import_node_path.join)(installerRoot, "CodexBridge.png"); + let lastWindowsServiceStart = 0; + let authorizationCache = null; + let authorizationPromise = null; + let authorizationGeneration = 0; + let lifecycleOperation = null; + function resetAuthorizationCache() { + authorizationGeneration += 1; + authorizationCache = null; + authorizationPromise = null; + } + async function authorizationHeaders() { + if (authorizationCache) return authorizationCache; + if (authorizationPromise) return authorizationPromise; + const generation = authorizationGeneration; + const operation = (async () => { + try { + const token = (await (0, import_promises.readFile)(tokenPath, "utf8")).trim(); + const headers = token ? Object.freeze({ Authorization: `Bearer ${token}` }) : {}; + if (token && generation === authorizationGeneration) authorizationCache = headers; + return headers; + } catch { + return {}; + } + })(); + authorizationPromise = operation; + try { + return await operation; + } finally { + if (authorizationPromise === operation) authorizationPromise = null; + } + } async function probeBridge() { try { - const response = await fetch(`${bridgeUrl}/health`, { + const response = await fetchImpl(`${bridgeUrl}/health`, { + headers: await authorizationHeaders(), signal: AbortSignal.timeout(1200) }); const payload = await response.json(); if (!response.ok || payload.ok === false) throw new Error(payload.error || `Bridge HTTP ${response.status}`); - return { serviceOnline: true, cdpConnected: Boolean(payload.codexConnected), serviceError: null }; + return { + serviceOnline: true, + cdpConnected: Boolean(payload.codexConnected), + serviceVersion: typeof payload.bridgeVersion === "string" ? payload.bridgeVersion : null, + serviceRuntimeHash: typeof payload.runtimeHash === "string" ? payload.runtimeHash : null, + serviceNativeRuntimeHash: typeof payload.nativeRuntimeHash === "string" ? payload.nativeRuntimeHash : null, + serviceError: null + }; } catch (error) { - return { serviceOnline: false, cdpConnected: false, serviceError: error.message }; + return { + serviceOnline: false, + cdpConnected: false, + serviceVersion: null, + serviceRuntimeHash: null, + serviceNativeRuntimeHash: null, + serviceError: error.message + }; } } async function status() { - const [appInstalled, runtimeInstalled, agentInstalled, metadata, probe] = await Promise.all([ - exists(bridgeExecutable, import_node_fs.constants.X_OK), + const [runtimeInstalled, tokenInstalled, metadata, probe, bundledRuntimeHash, installedRuntimeHash] = await Promise.all([ exists(bridgeRuntime), - exists(bridgeAgent), + exists(tokenPath), readJson(installMetadata), - probeBridge() + probeBridge(), + fileSha256(bundledRuntime), + fileSha256(bridgeRuntime) ]); - const installed = appInstalled && runtimeInstalled && agentInstalled; + const bundledNativeRuntimeHash = platform === "win32" ? await verifiedNativeRuntimeHash(bundledNativeRuntime) : null; + const installedNativeRuntimeHash = platform === "win32" && metadata?.nativeRuntimeHash ? await verifiedNativeRuntimeHash((0, import_node_path.join)(nativeRuntimesRoot, metadata.nativeRuntimeHash)) : null; + const nativeRuntimeInstalled = platform !== "win32" || Boolean( + bundledNativeRuntimeHash && installedNativeRuntimeHash === bundledNativeRuntimeHash && metadata?.nativeRuntimeHash === bundledNativeRuntimeHash + ); + const macAppInstalled = platform === "darwin" ? await exists(bridgeExecutable, import_node_fs.constants.X_OK) : runtimeInstalled; + const agentInstalled = platform === "darwin" ? await exists(bridgeAgent) : true; + const installed = macAppInstalled && runtimeInstalled && tokenInstalled && agentInstalled && nativeRuntimeInstalled; + const installationDetected = Boolean( + runtimeInstalled || tokenInstalled || metadata || probe.serviceOnline || platform === "darwin" && (macAppInstalled || agentInstalled) + ); + const metadataMatchesBundle = Boolean( + bundledRuntimeHash && metadata?.version === version && metadata?.runtimeHash === bundledRuntimeHash && installedRuntimeHash === bundledRuntimeHash && nativeRuntimeInstalled + ); + const runningMatchesBundle = !probe.serviceOnline || Boolean( + bundledRuntimeHash && probe.serviceVersion === version && probe.serviceRuntimeHash === bundledRuntimeHash && (platform !== "win32" || probe.serviceNativeRuntimeHash === bundledNativeRuntimeHash) + ); return { - supported: platform === "darwin" && Number.isInteger(uid), + supported: platform === "win32" || platform === "darwin" && Number.isInteger(uid), + platform, installed, - appInstalled, - serviceInstalled: runtimeInstalled && agentInstalled, + installationDetected, + appInstalled: macAppInstalled, + serviceInstalled: runtimeInstalled && tokenInstalled && agentInstalled, installedVersion: metadata?.version || null, bundledVersion: version, - needsUpdate: !installed || metadata?.version !== version, - appPath: bridgeApp, + bundledRuntimeHash, + installedRuntimeHash, + bundledNativeRuntimeHash, + installedNativeRuntimeHash, + needsUpdate: !installed || !metadataMatchesBundle || !runningMatchesBundle, + appPath: platform === "darwin" ? bridgeApp : appRoot, nodeExecutable: metadata?.nodeExecutable || null, nodeVersion: metadata?.nodeVersion || null, nodeSource: metadata?.nodeSource || null, + codexChannel: metadata?.codexChannel || codexChannel, ...probe }; } - async function buildIcon() { + async function buildMacIcon() { const iconset = (0, import_node_path.join)(appRoot, "CodexBridge.iconset"); await (0, import_promises.rm)(iconset, { recursive: true, force: true }); await (0, import_promises.mkdir)(iconset, { recursive: true }); @@ -3873,49 +4254,24 @@ function createBridgeInstaller({ ["icon_512x512.png", 512], ["icon_512x512@2x.png", 1024] ]) { - await execute("/usr/bin/sips", [ - "-z", - String(size), - String(size), - bundledIcon, - "--out", - (0, import_node_path.join)(iconset, name) - ]); + await execute("/usr/bin/sips", ["-z", String(size), String(size), bundledIcon, "--out", (0, import_node_path.join)(iconset, name)]); } await execute("/usr/bin/iconutil", ["-c", "icns", iconset, "-o", bridgeIcon]); } finally { await (0, import_promises.rm)(iconset, { recursive: true, force: true }); } } - async function install() { - if (platform !== "darwin" || !Number.isInteger(uid)) { - throw new Error("Codex Bridge installation is supported on macOS only."); - } - if (!await exists(bundledRuntime) || !await exists(bundledIcon)) { - throw new Error("The plugin does not contain the Codex Bridge installation resources."); - } - const nodeRuntime = await selectBridgeNodeRuntime({ - home, - fallbackNodeExecutable: nodeExecutable, - environmentPath, - platform, - execute - }); - await (0, import_promises.mkdir)(appRoot, { recursive: true, mode: 448 }); - await (0, import_promises.chmod)(appRoot, 448); + async function installMac(nodeRuntime, appVersion = version) { await (0, import_promises.mkdir)(userApplications, { recursive: true }); await (0, import_promises.mkdir)(agentsRoot, { recursive: true }); - await (0, import_promises.copyFile)(bundledRuntime, bridgeRuntime); await (0, import_promises.rm)(bridgeApp, { recursive: true, force: true }); await (0, import_promises.mkdir)(bridgeMacOS, { recursive: true }); await (0, import_promises.mkdir)(bridgeLicenses, { recursive: true }); for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { - const source = (0, import_node_path.join)(installerRoot, notice); - await (0, import_promises.copyFile)(source, (0, import_node_path.join)(bridgeLicenses, notice)); - await (0, import_promises.copyFile)(source, (0, import_node_path.join)(appRoot, notice)); + await (0, import_promises.copyFile)((0, import_node_path.join)(installerRoot, notice), (0, import_node_path.join)(bridgeLicenses, notice)); } - await buildIcon(); - const info = ` + await buildMacIcon(); + await (0, import_promises.writeFile)((0, import_node_path.join)(bridgeContents, "Info.plist"), ` CFBundleDisplayNameCodex Bridge @@ -3924,115 +4280,467 @@ function createBridgeInstaller({ CFBundleIdentifierio.opencodexmicro.bridge CFBundleNameCodex Bridge CFBundlePackageTypeAPPL - CFBundleShortVersionString${xml(version)} - CFBundleVersion${xml(version)} + CFBundleShortVersionString${xml(appVersion)} + CFBundleVersion${xml(appVersion)} LSMinimumSystemVersion13.0 LSUIElement - NSHighResolutionCapable -`; - await (0, import_promises.writeFile)((0, import_node_path.join)(bridgeContents, "Info.plist"), info); - const launcher = `#!/bin/zsh +`); + await (0, import_promises.writeFile)(bridgeExecutable, `#!/bin/zsh set -u -unsetopt BG_NICE - codex_binary="/Applications/ChatGPT.app/Contents/MacOS/ChatGPT" -bridge_log="$HOME/Library/Logs/OpenCodexMicro-codex-bridge.log" - -if [[ ! -x "$codex_binary" ]]; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex was not found at /Applications/ChatGPT.app." as critical' - exit 1 -fi - +if [[ ! -x "$codex_binary" ]]; then exit 1; fi if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then /usr/bin/osascript -e 'tell application id "com.openai.codex" to quit' - for attempt in {1..80}; do - /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break - /bin/sleep 0.1 - done + for attempt in {1..80}; do /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1 || break; /bin/sleep 0.1; done fi - -if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - /usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex did not quit, so bridge parameters could not be applied. Quit Codex and try again." as critical' - exit 1 -fi - -/usr/bin/nohup "$codex_binary" \\ - --remote-debugging-address=127.0.0.1 \\ - --remote-debugging-port=9222 \\ - --remote-allow-origins=http://127.0.0.1:9222 \\ - >>"$bridge_log" 2>&1 & - -for attempt in {1..300}; do - if /usr/bin/curl --noproxy '*' --silent --fail --max-time 0.2 \\ - http://127.0.0.1:9222/json/version >/dev/null 2>&1; then - exit 0 - fi - if ! /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then - break - fi - /bin/sleep 0.1 -done - -/usr/bin/osascript -e 'display alert "Codex Bridge" message "Codex started, but the bridge endpoint is unavailable. Quit Codex and launch Codex Bridge again." as critical' -exit 1 -`; - await (0, import_promises.writeFile)(bridgeExecutable, launcher, { mode: 493 }); +if /usr/bin/pgrep -x ChatGPT >/dev/null 2>&1; then exit 1; fi +/usr/bin/nohup "$codex_binary" ${["--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", "--remote-allow-origins=http://127.0.0.1:9222"].join(" ")} >/dev/null 2>&1 & +`, { mode: 493 }); await (0, import_promises.chmod)(bridgeExecutable, 493); await execute("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", bridgeApp]); - const plist = ` + await (0, import_promises.writeFile)(bridgeAgent, ` Labelio.opencodexmicro.bridge - ProgramArguments - ${xml(nodeRuntime.executable)} - ${xml(bridgeRuntime)} - - RunAtLoad - KeepAlive - ProcessTypeBackground - ThrottleInterval2 + ProgramArguments${xml(nodeRuntime.executable)}${xml(bridgeRuntime)} + EnvironmentVariables + CODEX_BRIDGE_DATA_ROOT${xml(appRoot)} + + RunAtLoadKeepAlive + ProcessTypeBackgroundThrottleInterval2 StandardOutPath${xml((0, import_node_path.join)(appRoot, "bridge.log"))} StandardErrorPath${xml((0, import_node_path.join)(appRoot, "bridge-error.log"))} +`, { mode: 420 }); + await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + } + async function acquireLifecycleLock() { + await (0, import_promises.mkdir)(appRoot, { recursive: true, mode: 448 }); + return acquireFilesystemLock({ lockPath: lifecycleLock, wait }); + } + function serializeLifecycle(operation) { + if (lifecycleOperation) return lifecycleOperation; + const running = (async () => { + const release = await acquireLifecycleLock(); + try { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await (0, import_promises.rename)(runtimeBackup, bridgeRuntime); + } + if (!await exists(installMetadata) && await exists(metadataBackup)) { + await (0, import_promises.rename)(metadataBackup, installMetadata); + } + return await operation(); + } finally { + await release(); + } + })(); + lifecycleOperation = running; + return running.finally(() => { + if (lifecycleOperation === running) lifecycleOperation = null; + }); + } + async function stopWindowsService() { + const recordedPid = Number((await (0, import_promises.readFile)(pidPath, "utf8").catch(() => "")).trim()); + const stopScript = `$target=$env:CODEX_BRIDGE_TARGET_RUNTIME; $targetPid=0; [void][int]::TryParse($env:CODEX_BRIDGE_TARGET_PID, [ref]$targetPid); $candidates=@(Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.Name -like 'node*' -and $_.CommandLine -and ($_.CommandLine.TrimEnd().EndsWith($target, [System.StringComparison]::OrdinalIgnoreCase) -or $_.CommandLine.TrimEnd().EndsWith(('"' + $target + '"'), [System.StringComparison]::OrdinalIgnoreCase)) }); $processes=if ($targetPid -gt 0) { @($candidates | Where-Object { $_.ProcessId -eq $targetPid }) } else { $candidates }; $processes | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $processes | ForEach-Object { try { Wait-Process -Id $_.ProcessId -Timeout 5 -ErrorAction Stop } catch {} }`; + await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", stopScript], { + timeout: 7e3, + windowsHide: true, + env: { + ...process.env, + CODEX_BRIDGE_TARGET_RUNTIME: bridgeRuntime, + CODEX_BRIDGE_TARGET_PID: Number.isInteger(recordedPid) && recordedPid > 0 ? String(recordedPid) : "" + } + }); + await (0, import_promises.rm)(pidPath, { force: true }); + lastWindowsServiceStart = 0; + } + async function waitForServiceOffline() { + const deadline = Date.now() + Math.min(serviceStartTimeoutMs, 5e3); + do { + if (!(await probeBridge()).serviceOnline) return; + await wait(100); + } while (Date.now() < deadline); + throw new Error("The managed Codex Bridge process did not stop before replacement."); + } + async function stopService() { + if (platform === "darwin") { + const wasOnline = (await probeBridge()).serviceOnline; + if (await exists(bridgeAgent)) { + try { + await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); + } catch (error) { + if (wasOnline) throw new Error(`Codex Bridge LaunchAgent could not be stopped: ${error.message}`); + } + } + if (wasOnline) await waitForServiceOffline(); + return; + } + if (platform === "win32") await stopWindowsService(); + } + async function commitRuntime(stagedRuntime) { + await (0, import_promises.rm)(runtimeBackup, { force: true }); + if (await exists(bridgeRuntime)) await (0, import_promises.rename)(bridgeRuntime, runtimeBackup); + try { + await (0, import_promises.rename)(stagedRuntime, bridgeRuntime); + } catch (error) { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await (0, import_promises.rename)(runtimeBackup, bridgeRuntime); + } + throw error; + } + } + async function restorePreviousRuntime(previousRuntime) { + await (0, import_promises.rm)(bridgeRuntime, { force: true }); + if (await exists(runtimeBackup)) { + await (0, import_promises.rename)(runtimeBackup, bridgeRuntime); + return; + } + if (previousRuntime) { + const rollbackRuntime = (0, import_node_path.join)(appRoot, `.bridge.mjs.rollback-${process.pid}`); + await (0, import_promises.writeFile)(rollbackRuntime, previousRuntime); + await (0, import_promises.rename)(rollbackRuntime, bridgeRuntime); + } + } + async function commitMetadata(metadataText) { + const stagedMetadata = (0, import_node_path.join)(appRoot, `.install.json.installing-${process.pid}`); + await (0, import_promises.writeFile)(stagedMetadata, metadataText, { mode: 384 }); + await (0, import_promises.rm)(metadataBackup, { force: true }); + if (await exists(installMetadata)) await (0, import_promises.rename)(installMetadata, metadataBackup); + try { + await (0, import_promises.rename)(stagedMetadata, installMetadata); + } catch (error) { + if (!await exists(installMetadata) && await exists(metadataBackup)) { + await (0, import_promises.rename)(metadataBackup, installMetadata); + } + throw error; + } + } + async function restorePreviousMetadata(previousMetadataText, useBackup = true) { + await (0, import_promises.rm)(installMetadata, { force: true }); + if (useBackup && await exists(metadataBackup)) { + await (0, import_promises.rename)(metadataBackup, installMetadata); + return; + } + await (0, import_promises.rm)(metadataBackup, { force: true }); + if (previousMetadataText) { + await (0, import_promises.writeFile)(installMetadata, previousMetadataText, { mode: 384 }); + } + } + async function discardTransactionBackups() { + await Promise.all([ + (0, import_promises.rm)(runtimeBackup, { force: true }), + (0, import_promises.rm)(metadataBackup, { force: true }) + ]); + } + async function startInstalledService(metadata) { + if (!metadata?.nodeExecutable) { + throw new Error("Codex Bridge runtime metadata is missing its Node.js executable."); + } + if (platform === "darwin") { + await installMac({ + executable: metadata.nodeExecutable, + version: metadata.nodeVersion || "unknown", + source: metadata.nodeSource || "unknown" + }, metadata.version || "0.0.0"); + } + if (platform === "win32") await ensureServiceUnlocked(); + } + async function waitForAnyService() { + const deadline = Date.now() + serviceStartTimeoutMs; + do { + const probe = await probeBridge(); + if (probe.serviceOnline) return probe; + await wait(100); + } while (Date.now() < deadline); + throw new Error("The restored Codex Bridge service did not become reachable."); + } + async function rollbackInterruptedUpdate() { + const hasRuntimeBackup = await exists(runtimeBackup); + const hasMetadataBackup = await exists(metadataBackup); + if (!hasRuntimeBackup && !hasMetadataBackup) return false; + await stopService(); + if (hasRuntimeBackup) { + await (0, import_promises.rm)(bridgeRuntime, { force: true }); + await (0, import_promises.rename)(runtimeBackup, bridgeRuntime); + } + if (hasMetadataBackup) { + await (0, import_promises.rm)(installMetadata, { force: true }); + await (0, import_promises.rename)(metadataBackup, installMetadata); + } + resetAuthorizationCache(); + const restoredMetadata = await readJson(installMetadata); + await startInstalledService(restoredMetadata); + await waitForAnyService(); + return true; + } + async function waitForExpectedService(expectedRuntimeHash, expectedVersion = version, expectedNativeRuntimeHash = null) { + const deadline = Date.now() + serviceStartTimeoutMs; + let lastProbe = null; + do { + lastProbe = await probeBridge(); + if (lastProbe.serviceOnline && lastProbe.serviceVersion === expectedVersion && lastProbe.serviceRuntimeHash === expectedRuntimeHash && (platform !== "win32" || lastProbe.serviceNativeRuntimeHash === expectedNativeRuntimeHash)) { + return lastProbe; + } + await wait(100); + } while (Date.now() < deadline); + const detail = lastProbe?.serviceOnline ? "the running process reported a different build" : "the restarted service did not become reachable"; + throw new Error(`Codex Bridge ${expectedVersion} restart failed: ${detail}.`); + } + async function installUnlocked() { + if (!(platform === "win32" || platform === "darwin" && Number.isInteger(uid))) { + throw new Error("Codex Bridge installation is supported on Windows and macOS only."); + } + if (!await exists(bundledRuntime) || !await exists(bundledIcon) || platform === "win32" && !await verifiedNativeRuntimeHash(bundledNativeRuntime)) { + throw new Error("The plugin does not contain the Codex Bridge installation resources."); + } + const nodeRuntime = await selectBridgeNodeRuntime({ + home, + fallbackNodeExecutable: nodeExecutable, + environmentPath, + platform, + execute + }); + const previousStatus = await status(); + const previousRuntime = await (0, import_promises.readFile)(bridgeRuntime).catch(() => null); + const previousRuntimeHash = previousRuntime ? (0, import_node_crypto.createHash)("sha256").update(previousRuntime).digest("hex") : null; + const previousMetadataText = await (0, import_promises.readFile)(installMetadata, "utf8").catch(() => null); + const previousMetadata = await readJson(installMetadata); + const nativeRuntimeHash = platform === "win32" ? previousStatus.bundledNativeRuntimeHash : null; + const nativeRuntime = nativeRuntimeHash ? (0, import_node_path.join)(nativeRuntimesRoot, nativeRuntimeHash) : null; + let createdNativeRuntime = false; + if (nativeRuntimeHash && await verifiedNativeRuntimeHash(nativeRuntime) !== nativeRuntimeHash) { + const stagedNativeRuntime = (0, import_node_path.join)(appRoot, `.native-runtime.installing-${process.pid}`); + await (0, import_promises.rm)(stagedNativeRuntime, { recursive: true, force: true }); + await (0, import_promises.cp)(bundledNativeRuntime, stagedNativeRuntime, { recursive: true }); + if (await verifiedNativeRuntimeHash(stagedNativeRuntime) !== nativeRuntimeHash) { + await (0, import_promises.rm)(stagedNativeRuntime, { recursive: true, force: true }); + throw new Error("The bundled native focus runtime could not be verified."); + } + await (0, import_promises.mkdir)(nativeRuntimesRoot, { recursive: true }); + await (0, import_promises.rm)(nativeRuntime, { recursive: true, force: true }); + await (0, import_promises.rename)(stagedNativeRuntime, nativeRuntime); + createdNativeRuntime = true; + } + const stagedRuntime = (0, import_node_path.join)(appRoot, `.bridge.mjs.installing-${process.pid}`); + await (0, import_promises.mkdir)(appRoot, { recursive: true, mode: 448 }); + if (platform !== "win32") await (0, import_promises.chmod)(appRoot, 448); + await (0, import_promises.rm)(stagedRuntime, { force: true }); + await (0, import_promises.copyFile)(bundledRuntime, stagedRuntime); + const runtimeHash = await fileSha256(stagedRuntime); + if (!runtimeHash) throw new Error("The bundled Codex Bridge runtime could not be verified."); + console.log(`[Codex Bridge] ${previousStatus.installed ? "Updating" : "Installing"} ${version}; stopping only the managed Bridge process.`); + try { + await stopService(); + await commitRuntime(stagedRuntime); + for (const notice of ["LICENSE", "NOTICE.md", "THIRD_PARTY_NOTICES.md"]) { + await (0, import_promises.copyFile)((0, import_node_path.join)(installerRoot, notice), (0, import_node_path.join)(appRoot, notice)); + } + if (!await exists(tokenPath)) { + await (0, import_promises.writeFile)(tokenPath, `${(0, import_node_crypto.randomBytes)(32).toString("base64url")} +`, { mode: 384 }); + } + resetAuthorizationCache(); + await commitMetadata(`${JSON.stringify({ + version, + runtimeHash, + nativeRuntimeHash, + platform, + codexChannel, + nodeExecutable: nodeRuntime.executable, + nodeVersion: nodeRuntime.version, + nodeSource: nodeRuntime.source, + installedAt: (/* @__PURE__ */ new Date()).toISOString() + }, null, 2)} +`); + if (platform === "darwin") await installMac(nodeRuntime); + if (platform === "win32") await ensureServiceUnlocked(); + await waitForExpectedService(runtimeHash, version, nativeRuntimeHash); + const installed = await status(); + if (installed.needsUpdate) throw new Error("The restarted Codex Bridge did not match the bundled runtime."); + await discardTransactionBackups(); + console.log(`[Codex Bridge] ${version} is installed and the restarted process reported the expected build.`); + return installed; + } catch (error) { + console.error(`[Codex Bridge] ${version} update failed; restoring the previous managed runtime.`); + let rollbackError = null; + try { + await stopService(); + if (previousRuntime) { + await restorePreviousRuntime(previousRuntime); + const recoveryVersion = previousMetadata?.version || previousStatus.serviceVersion || "0.0.0"; + const recoveryMetadataText = previousMetadata ? previousMetadataText : `${JSON.stringify({ + version: recoveryVersion, + runtimeHash: previousRuntimeHash, + nativeRuntimeHash: previousMetadata?.nativeRuntimeHash || null, + platform, + codexChannel: previousStatus.codexChannel || codexChannel, + nodeExecutable: nodeRuntime.executable, + nodeVersion: nodeRuntime.version, + nodeSource: nodeRuntime.source, + installedAt: (/* @__PURE__ */ new Date()).toISOString(), + recovered: true + }, null, 2)} `; - await (0, import_promises.writeFile)(bridgeAgent, plist, { mode: 420 }); - await (0, import_promises.writeFile)(installMetadata, `${JSON.stringify({ - version, - nodeExecutable: nodeRuntime.executable, - nodeVersion: nodeRuntime.version, - nodeSource: nodeRuntime.source, - installedAt: (/* @__PURE__ */ new Date()).toISOString() - }, null, 2)} + await restorePreviousMetadata(recoveryMetadataText, Boolean(previousMetadata)); + resetAuthorizationCache(); + const restoredMetadata = await readJson(installMetadata); + if (restoredMetadata?.nodeExecutable) { + const previousNodeRuntime = { + executable: restoredMetadata.nodeExecutable, + version: restoredMetadata.nodeVersion || "unknown", + source: restoredMetadata.nodeSource || "unknown" + }; + if (platform === "darwin") await installMac(previousNodeRuntime, restoredMetadata.version || "0.0.0"); + if (platform === "win32") await ensureServiceUnlocked(); + } + } else { + if (platform === "darwin") { + await (0, import_promises.rm)(bridgeAgent, { force: true }); + await (0, import_promises.rm)(bridgeApp, { recursive: true, force: true }); + } + await (0, import_promises.rm)(appRoot, { recursive: true, force: true }); + resetAuthorizationCache(); + } + if (createdNativeRuntime && nativeRuntime && previousMetadata?.nativeRuntimeHash !== nativeRuntimeHash) { + await (0, import_promises.rm)(nativeRuntime, { recursive: true, force: true }); + } + } catch (rollbackFailure) { + rollbackError = rollbackFailure; + } + const rollbackDetail = rollbackError ? ` Rollback also failed: ${rollbackError.message}` : " Previous runtime restored."; + throw new Error(`Codex Bridge installation failed: ${error.message}${rollbackDetail}`); + } finally { + await (0, import_promises.rm)(stagedRuntime, { force: true }); + } + } + async function install() { + return serializeLifecycle(installUnlocked); + } + async function ensureServiceUnlocked() { + if (platform !== "win32") return status(); + const probe = await probeBridge(); + if (probe.serviceOnline || Date.now() - lastWindowsServiceStart < 2e3) return { ...await status(), ...probe }; + const metadata = await readJson(installMetadata); + const nativeRuntimeValid = !metadata?.nativeRuntimeHash || await verifiedNativeRuntimeHash((0, import_node_path.join)(nativeRuntimesRoot, metadata.nativeRuntimeHash)) === metadata.nativeRuntimeHash; + if (!metadata?.nodeExecutable || !await exists(bridgeRuntime) || !await exists(tokenPath) || !nativeRuntimeValid) { + throw new Error("Codex Bridge is not installed. Use Install / Repair first."); + } + const token = (await (0, import_promises.readFile)(tokenPath, "utf8")).trim(); + const child = spawnProcess(metadata.nodeExecutable, [bridgeRuntime], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + CODEX_BRIDGE_DATA_ROOT: appRoot, + CODEX_BRIDGE_TOKEN: token, + ...metadata.nativeRuntimeHash ? { + CODEX_BRIDGE_NATIVE_ROOT: (0, import_node_path.join)(nativeRuntimesRoot, metadata.nativeRuntimeHash), + CODEX_BRIDGE_NATIVE_HASH: metadata.nativeRuntimeHash + } : {} + } + }); + if (Number.isInteger(child.pid) && child.pid > 0) { + await (0, import_promises.writeFile)(pidPath, `${child.pid} `, { mode: 384 }); - try { - await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { + } else { + await (0, import_promises.rm)(pidPath, { force: true }); } - await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + child.unref?.(); + lastWindowsServiceStart = Date.now(); return status(); } + async function ensureService() { + return serializeLifecycle(ensureServiceUnlocked); + } + async function ensureCurrent() { + return serializeLifecycle(async () => { + const current = await status(); + if (!current.installationDetected) return current; + const interruptedUpdate = await exists(runtimeBackup) || await exists(metadataBackup); + if (interruptedUpdate && current.needsUpdate) { + await rollbackInterruptedUpdate(); + console.log("[Codex Bridge] Interrupted update rolled back; retrying the bundled update in the same lifecycle operation."); + return installUnlocked(); + } + if (!current.installed || current.needsUpdate) { + console.log(`[Codex Bridge] Installed or running build differs from bundled ${version}; starting automatic update.`); + return installUnlocked(); + } + if (!current.serviceOnline) { + try { + await startInstalledService(await readJson(installMetadata)); + await waitForExpectedService( + current.bundledRuntimeHash, + version, + current.bundledNativeRuntimeHash + ); + const ready = await status(); + await discardTransactionBackups(); + return ready; + } catch (error) { + if (interruptedUpdate && await rollbackInterruptedUpdate()) { + console.log("[Codex Bridge] Interrupted restart rolled back; retrying the bundled update once."); + return installUnlocked(); + } + throw error; + } + } + await discardTransactionBackups(); + return current; + }); + } async function launch() { - if (!await exists(bridgeExecutable, import_node_fs.constants.X_OK)) { - throw new Error("Codex Bridge.app is not installed."); + const current = await ensureCurrent(); + if (!current.installed) throw new Error("Codex Bridge is not installed. Use Install / Repair first."); + if (platform === "darwin") { + await execute("/usr/bin/open", [bridgeApp]); + return status(); } - await execute("/usr/bin/open", [bridgeApp]); - return status(); + if (platform === "win32") { + await ensureService(); + try { + await discoverDebugEndpoint({ platform, execute, fetchImpl }); + const response = await fetchImpl(`${bridgeUrl}/focus`, { + method: "POST", + headers: await authorizationHeaders(), + signal: AbortSignal.timeout(1200) + }); + const payload = await response.json(); + if (!response.ok || payload?.ok !== true) { + throw new Error(payload?.error || "Codex focus request failed"); + } + } catch (error) { + if (!/local debug bridge/.test(error.message)) throw error; + await launchWindowsCodex({ channel: codexChannel, execute, spawnProcess }); + } + return status(); + } + throw new Error(`Codex Bridge launch is not supported on ${platform}.`); } - async function uninstall() { - if (platform !== "darwin" || !Number.isInteger(uid)) { - throw new Error("Codex Bridge uninstallation is supported on macOS only."); + async function uninstallUnlocked() { + if (!(platform === "win32" || platform === "darwin" && Number.isInteger(uid))) { + throw new Error("Codex Bridge uninstallation is supported on Windows and macOS only."); } - try { - await execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { + if (platform === "darwin") { + await stopService(); + await (0, import_promises.rm)(bridgeAgent, { force: true }); + await (0, import_promises.rm)(bridgeApp, { recursive: true, force: true }); + } else { + try { + await stopService(); + } catch { + } } - await (0, import_promises.rm)(bridgeAgent, { force: true }); await (0, import_promises.rm)(appRoot, { recursive: true, force: true }); - await (0, import_promises.rm)(bridgeApp, { recursive: true, force: true }); + resetAuthorizationCache(); return status(); } - return { status, install, launch, uninstall }; + async function uninstall() { + return serializeLifecycle(uninstallUnlocked); + } + return { status, install, launch, uninstall, ensureService, ensureCurrent, authorizationHeaders }; } // plugin/app.js @@ -4048,6 +4756,47 @@ var bridgeSetup = createBridgeInstaller({ bridgeUrl: BRIDGE_URL, version: MANIFEST.Version }); +var bridgeReconcilePromise = null; +var bridgeReconcileRetryTimer = null; +var bridgeReconcileFailures = 0; +function scheduleBridgeReconcileRetry() { + if (bridgeReconcileRetryTimer || bridgeReconcileFailures >= 3) return; + const delay = Math.min(1e4, 2e3 * 2 ** bridgeReconcileFailures); + bridgeReconcileFailures += 1; + bridgeReconcileRetryTimer = setTimeout(() => { + bridgeReconcileRetryTimer = null; + void ensureBundledBridgeCurrent().catch((error) => { + console.error(`[Codex Micro] Bridge reconciliation retry failed: ${error.message}`); + }); + }, delay); + bridgeReconcileRetryTimer.unref?.(); +} +function ensureBundledBridgeCurrent() { + if (!bridgeReconcilePromise) { + const running = (async () => { + const before = await bridgeSetup.status(); + if (!before.installationDetected) return before; + if (before.needsUpdate) { + console.log(`[Codex Micro] Updating the installed Bridge to bundled version ${before.bundledVersion}.`); + } + const after = await bridgeSetup.ensureCurrent(); + if (before.needsUpdate) { + console.log(`[Codex Micro] Bridge ${after.installedVersion} restarted with the bundled runtime.`); + } + return after; + })(); + bridgeReconcilePromise = running; + void running.then(() => { + bridgeReconcileFailures = 0; + if (bridgeReconcileRetryTimer) clearTimeout(bridgeReconcileRetryTimer); + bridgeReconcileRetryTimer = null; + }, () => { + if (bridgeReconcilePromise === running) bridgeReconcilePromise = null; + scheduleBridgeReconcileRetry(); + }); + } + return bridgeReconcilePromise; +} var USAGE_BASE64 = (0, import_node_fs2.readFileSync)( (0, import_node_path2.resolve)(PLUGIN_ROOT, "assets/icons/usage-base.png") ).toString("base64"); @@ -4060,21 +4809,36 @@ var ACTION_LABELS = Object.freeze({ fork: "FORK", steer: "STEER", mic: "MIC", - submit: "SUBMIT" + submit: "SUBMIT", + "model-sol-high": "SOL HIGH", + "model-luna-max": "LUNA MAX", + "model-sol-medium": "SOL MED" }); -var TASK_ICON_PATHS = Object.freeze({ - idle: "assets/icons/task-idle.png", - working: "assets/icons/task-working.png", - complete: "assets/icons/task-complete.png", - attention: "assets/icons/task-attention.png", - error: "assets/icons/task-error.png" +var DEBUG_MODEL_PRESETS = Object.freeze({ + "model-sol-high": Object.freeze({ model: "gpt-5.6-sol", effort: "high" }), + "model-luna-max": Object.freeze({ model: "gpt-5.6-luna", effort: "max" }), + "model-sol-medium": Object.freeze({ model: "gpt-5.6-sol", effort: "medium" }) }); +var TASK_STATUS_PALETTES = Object.freeze({ + idle: { frame: "#475467", accent: "#98a2b3" }, + working: { frame: "#0b5fcc", accent: "#2589f5" }, + complete: { frame: "#087443", accent: "#28b875" }, + attention: { frame: "#9a6700", accent: "#ed9f20" }, + error: { frame: "#b42318", accent: "#e34d62" } +}); +var TASK_COMPLETE_FLASH_PALETTE = Object.freeze({ frame: "#12b76a", accent: "#6ce9a6" }); +var TASK_TITLE_MAX_UNITS = 10; +var TASK_TITLE_MAX_LINES = 4; +var TASK_TITLE_SEGMENTER = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null; var socket; var reconnectTimer; var pollTimer; var pollInFlight = false; var latestState = null; var setupOperation = null; +var completeFlashOn = false; +var shortcutInvocationSequence = 0; +var forwardedTraceEvents = /* @__PURE__ */ new Map(); function contextOf(message) { return String(message.actionid || `${message.uuid}___${message.key}`); } @@ -4200,17 +4964,188 @@ async function handleBridgeSetupMessage(message) { } await sendBridgeSetupStatus(message, { result, error: failure }); } -function taskIconPath(status) { +function taskStatusKind(status) { const value = String(status || "").toLowerCase(); - if (["working", "thinking", "running", "in_progress"].includes(value)) return TASK_ICON_PATHS.working; - if (["unread", "complete", "completed", "done", "success"].includes(value)) return TASK_ICON_PATHS.complete; - if (["attention", "notification", "input", "approval", "waiting_input", "needs_input"].includes(value)) return TASK_ICON_PATHS.attention; - if (["error", "failed", "failure"].includes(value)) return TASK_ICON_PATHS.error; - return TASK_ICON_PATHS.idle; + if (["working", "thinking", "running", "in_progress"].includes(value)) return "working"; + if (["unread", "complete", "completed", "done", "success"].includes(value)) return "complete"; + if (["attention", "notification", "input", "approval", "waiting_input", "needs_input"].includes(value)) return "attention"; + if (["error", "failed", "failure"].includes(value)) return "error"; + return "idle"; +} +function debugShortcut(uuid) { + const slot = taskSlot(uuid); + if (slot !== null) { + return Object.freeze({ name: `task-${slot + 1}`, kind: "task", target: `slot-${slot + 1}` }); + } + const action = actionName(uuid); + const preset = DEBUG_MODEL_PRESETS[action]; + if (!preset) return null; + return Object.freeze({ + name: action, + kind: "model-preset", + target: `model-${preset.model} effort-${preset.effort}` + }); +} +function shortcutErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timeout")) return "bridge-timeout"; + if (message.includes("is empty")) return "empty-task-slot"; + if (message.includes("fetch failed") || message.includes("econnrefused")) return "bridge-unavailable"; + if (message.includes("authorization") || message.includes("unauthorized") || message.includes("forbidden")) { + return "bridge-rejected"; + } + return "bridge-error"; +} +function logShortcut(instance, shortcut, level, event, fields = []) { + const message = `[Codex Micro] shortcut event=${event} name=${shortcut.name} kind=${shortcut.kind} ${fields.join(" ")}`.trim(); + if (level === "error") console.error(message); + else if (level === "warn") console.warn(message); + else console.log(message); + send({ + cmd: "logMessage", + uuid: PLUGIN_UUID, + actionid: "", + key: "", + level, + message + }); +} +function safeDiagnosticFields(event) { + const allowed = /* @__PURE__ */ new Set([ + "action", + "attempts", + "background", + "category", + "channel", + "complete", + "connection", + "currentEffort", + "durationMs", + "effortMatched", + "modelMatched", + "outcome", + "focusOk", + "path", + "phase", + "platform", + "reused", + "route", + "rowCount", + "slot", + "stage", + "targetEffort" + ]); + return Object.entries(event || {}).flatMap(([key, value]) => { + if (!allowed.has(key)) return []; + if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) { + return [`${key}=${value}`]; + } + const normalized = String(value || ""); + return /^[a-zA-Z0-9_.:-]{1,80}$/.test(normalized) ? [`${key}=${normalized}`] : []; + }); +} +async function forwardBridgeDiagnostics(instance, shortcut, traceId) { + try { + if (forwardedTraceEvents.get(traceId) === Infinity) return true; + const payload = await bridgeRequest("/diagnostics/trace", "POST", traceId); + const diagnostics = payload?.diagnostics; + if (!diagnostics || diagnostics.traceId !== traceId || !Array.isArray(diagnostics.events)) return false; + const seen = forwardedTraceEvents.get(traceId) || 0; + for (const event of diagnostics.events.slice(seen, 160)) { + const eventName = String(event?.event || ""); + if (!/^[a-z0-9.-]{1,80}$/.test(eventName)) continue; + logShortcut(instance, shortcut, event.outcome === "failed" ? "error" : "debug", "bridge-trace", [ + `trace=${traceId}`, + `bridgeEvent=${eventName}`, + `offsetMs=${Math.max(0, Math.round(Number(event.offsetMs) || 0))}`, + ...safeDiagnosticFields(event) + ]); + } + forwardedTraceEvents.set(traceId, diagnostics.events.length); + if (diagnostics.complete) forwardedTraceEvents.set(traceId, Infinity); + return Boolean(diagnostics.complete); + } catch { + return false; + } } -function shortTitle(value) { - const title = String(value || "Untitled").replace(/\s+/g, " ").trim(); - return title.length > 18 ? `${title.slice(0, 17)}\u2026` : title; +function collectBridgeDiagnostics(instance, shortcut, traceId) { + const delays = [0, 400, 1200, 2600, 5200]; + for (const delay of delays) { + const timer = setTimeout(() => void forwardBridgeDiagnostics(instance, shortcut, traceId), delay); + timer.unref?.(); + } + const cleanup = setTimeout(() => forwardedTraceEvents.delete(traceId), 1e4); + cleanup.unref?.(); +} +function titleGraphemes(value) { + const title = String(value || "Untitled").replace(/\s+/g, " ").trim() || "Untitled"; + if (!TASK_TITLE_SEGMENTER) return Array.from(title); + return Array.from(TASK_TITLE_SEGMENTER.segment(title), (item) => item.segment); +} +function graphemeUnits(value) { + if (/^\s$/u.test(value)) return 0.55; + if (new RegExp("^\\p{Mark}+$", "u").test(value)) return 0; + if (new RegExp("\\p{Extended_Pictographic}", "u").test(value) || /[\u1100-\u11ff\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff\ufe10-\ufe6f\uff01-\uff60\uffe0-\uffe6]/u.test(value)) return 2; + if (/^[ilI1.,'`:;|!\[\](){}]$/u.test(value)) return 0.55; + if (/^[mwMW@#%&]$/u.test(value)) return 1.35; + return 1; +} +function lineUnits(values) { + return values.reduce((total, value) => total + graphemeUnits(value), 0); +} +function trimLine(values) { + const result = [...values]; + while (result[0] === " ") result.shift(); + while (result.at(-1) === " ") result.pop(); + return result; +} +function taskTitleLines(value) { + let graphemes = titleGraphemes(value); + const maxTotalUnits = TASK_TITLE_MAX_UNITS * TASK_TITLE_MAX_LINES; + if (lineUnits(graphemes) > maxTotalUnits) { + const ellipsisUnits = graphemeUnits("\u2026"); + const clipped = []; + let used = 0; + for (const grapheme of graphemes) { + const units = graphemeUnits(grapheme); + if (used + units + ellipsisUnits > maxTotalUnits) break; + clipped.push(grapheme); + used += units; + } + graphemes = trimLine(clipped); + graphemes.push("\u2026"); + } + const lines = []; + while (graphemes.length && lines.length < TASK_TITLE_MAX_LINES) { + const line = []; + while (graphemes.length && lineUnits([...line, graphemes[0]]) <= TASK_TITLE_MAX_UNITS) { + line.push(graphemes.shift()); + } + if (!line.length) line.push(graphemes.shift()); + const trimmed = trimLine(line); + if (trimmed.length) lines.push(trimmed.join("")); + while (graphemes[0] === " ") graphemes.shift(); + } + if (graphemes.length && lines.length === TASK_TITLE_MAX_LINES && !lines.at(-1).endsWith("\u2026")) { + const lastLine = titleGraphemes(lines.at(-1)); + while (lastLine.length && lineUnits([...lastLine, "\u2026"]) > TASK_TITLE_MAX_UNITS) lastLine.pop(); + lines[lines.length - 1] = `${trimLine(lastLine).join("")}\u2026`; + } + return lines; +} +function taskIconData(kind, flashOn = false) { + const palette = kind === "complete" && flashOn ? TASK_COMPLETE_FLASH_PALETTE : TASK_STATUS_PALETTES[kind]; + const svg = ` + + + + + + + + `; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; } function setDisplay(instance, state, text) { const digest = `${state}:${text}`; @@ -4231,8 +5166,12 @@ function setDisplay(instance, state, text) { } }); } -function setTaskDisplay(instance, path, text) { - const digest = `path:${path}:${text}`; +function setTaskDisplay(instance, status, title) { + const kind = taskStatusKind(status); + const lines = taskTitleLines(title); + const displayTitle = lines.join("\n"); + const flashOn = kind === "complete" && completeFlashOn; + const digest = `task:${kind}:${flashOn}:${displayTitle}`; if (!instance.active || instance.lastDisplay === digest) return; instance.lastDisplay = digest; send({ @@ -4242,10 +5181,11 @@ function setTaskDisplay(instance, path, text) { uuid: instance.uuid, actionid: instance.actionid, key: instance.key, - type: 2, - path, + type: 1, + data: taskIconData(kind, flashOn), showtext: true, - textdata: text + textData: displayTitle, + textdata: displayTitle }] } }); @@ -4276,7 +5216,7 @@ function renderInstance(instance) { const action = actionName(instance.uuid); if (!latestState?.connected) { if (action === "navigate") { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Bridge Offline"); + setTaskDisplay(instance, "idle", "Bridge Offline"); } else { setDisplay(instance, 0, "Bridge Offline"); } @@ -4289,32 +5229,35 @@ function renderInstance(instance) { if (action === "navigate") { const task2 = latestState.slots?.[0]; if (!task2?.threadKey) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Latest Task"); + setTaskDisplay(instance, "idle", "Latest Task"); return; } - setTaskDisplay(instance, taskIconPath(task2.status), shortTitle(task2.title)); + setTaskDisplay(instance, task2.status, task2.title); return; } setDisplay(instance, 0, ACTION_LABELS[action] || "CODEX"); return; } if (!latestState?.connected) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, "Bridge Offline"); + setTaskDisplay(instance, "idle", "Bridge Offline"); return; } const task = latestState.slots?.[slot]; if (!task?.threadKey) { - setTaskDisplay(instance, TASK_ICON_PATHS.idle, `Task ${slot + 1}`); + setTaskDisplay(instance, "idle", `Task ${slot + 1}`); return; } - setTaskDisplay(instance, taskIconPath(task.status), shortTitle(task.title)); + setTaskDisplay(instance, task.status, task.title); } function renderAll() { for (const instance of instances.values()) renderInstance(instance); } -async function bridgeRequest(path, method = "GET") { +async function bridgeRequest(path, method = "GET", traceId = null) { + const headers = { ...await bridgeSetup.authorizationHeaders() }; + if (traceId) headers["X-Codex-Trace-Id"] = traceId; const response = await fetch(`${BRIDGE_URL}${path}`, { method, + headers, signal: AbortSignal.timeout(1200) }); const payload = await response.json(); @@ -4323,10 +5266,10 @@ async function bridgeRequest(path, method = "GET") { } return payload; } -async function openTaskSlot(slot) { +async function openTaskSlot(slot, traceId) { const task = latestState?.slots?.[slot]; if (!task?.threadKey) throw new Error(`Codex task slot ${slot + 1} is empty`); - await bridgeRequest(`/thread/${encodeURIComponent(task.threadKey)}/click?slot=${slot}`, "POST"); + await bridgeRequest(`/thread/${encodeURIComponent(task.threadKey)}/click?slot=${slot}`, "POST", traceId); } async function pollBridge() { if (pollInFlight) return; @@ -4337,15 +5280,52 @@ async function pollBridge() { latestState = { connected: false, error: error.message, slots: [] }; } finally { pollInFlight = false; + completeFlashOn = !completeFlashOn; renderAll(); } } async function invoke(instance, pressed) { const slot = taskSlot(instance.uuid); + const shortcut = debugShortcut(instance.uuid); + const invocation = shortcut ? ++shortcutInvocationSequence : null; + const traceId = shortcut ? (0, import_node_crypto2.randomUUID)() : null; + const phase = pressed ? "down" : "up"; + const startedAt = Date.now(); + if (shortcut) { + logShortcut(instance, shortcut, "debug", "received", [ + `invocation=${invocation}`, + `trace=${traceId}`, + `phase=${phase}`, + `target=${shortcut.target}` + ]); + } try { if (slot !== null) { - if (!pressed) return; - await openTaskSlot(slot); + if (!pressed) { + if (shortcut) { + logShortcut(instance, shortcut, "debug", "ignored", [ + `invocation=${invocation}`, + `phase=${phase}`, + "reason=keydown-only" + ]); + } + return; + } + if (shortcut) { + logShortcut(instance, shortcut, "debug", "dispatching", [ + `invocation=${invocation}`, + `phase=${phase}`, + "transport=bridge-http" + ]); + } + await openTaskSlot(slot, traceId); + if (shortcut) { + logShortcut(instance, shortcut, "info", "succeeded", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}` + ]); + } return; } const action = actionName(instance.uuid); @@ -4354,10 +5334,42 @@ async function invoke(instance, pressed) { if (pressed) await bridgeRequest("/focus", "POST"); return; } - await bridgeRequest(`/action/${action}/${pressed ? "down" : "up"}`, "POST"); + if (shortcut) { + logShortcut(instance, shortcut, "debug", "dispatching", [ + `invocation=${invocation}`, + `phase=${phase}`, + "transport=bridge-http" + ]); + } + await bridgeRequest(`/action/${action}/${pressed ? "down" : "up"}`, "POST", traceId); + if (shortcut) { + logShortcut(instance, shortcut, "info", "succeeded", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}` + ]); + } } catch (error) { - send({ cmd: "logMessage", uuid: instance.uuid, actionid: instance.actionid, key: instance.key, level: "error", message: error.message }); + if (shortcut) { + logShortcut(instance, shortcut, "error", "failed", [ + `invocation=${invocation}`, + `phase=${phase}`, + `durationMs=${Date.now() - startedAt}`, + `category=${shortcutErrorCategory(error)}` + ]); + } else { + send({ + cmd: "logMessage", + uuid: instance.uuid, + actionid: instance.actionid, + key: instance.key, + level: "error", + message: `[Codex Micro] action failed category=${shortcutErrorCategory(error)}` + }); + } send({ cmd: "showAlert", uuid: instance.uuid, actionid: instance.actionid, key: instance.key }); + } finally { + if (shortcut && pressed) collectBridgeDiagnostics(instance, shortcut, traceId); } } async function invokeEncoder(instance, message) { @@ -4449,6 +5461,11 @@ function connect() { socket = new wrapper_default(HOST_URL); socket.on("open", () => { send({ code: 0, cmd: "connected", uuid: PLUGIN_UUID }); + if (process.env.CODEX_BRIDGE_AUTOSTART !== "0") { + void ensureBundledBridgeCurrent().catch((error) => { + console.error(`[Codex Micro] Automatic Bridge reconciliation failed: ${error.message}`); + }); + } clearInterval(pollTimer); pollTimer = setInterval(() => void pollBridge(), 500); pollTimer.unref(); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs index bdf0f45..a9f2c2a 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/installer/bridge.mjs @@ -11,9 +11,16 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); +var __esm = (fn, res) => function __init() { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; +}; var __commonJS = (cb, mod) => function __require2() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) @@ -31,6 +38,208 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); +// ../../src/bridge/windows-focus.mjs +var windows_focus_exports = {}; +__export(windows_focus_exports, { + activateWindowsProcess: () => activateWindowsProcess, + clearWindowsFocusCache: () => clearWindowsFocusCache, + initializeWindowsFocusRuntime: () => initializeWindowsFocusRuntime +}); +import { createRequire } from "node:module"; +import { join } from "node:path"; +function sameWindow(left, right) { + return left != null && right != null && String(left) === String(right); +} +async function loadKoffi() { + const nativeRoot = process.env.CODEX_BRIDGE_NATIVE_ROOT; + if (nativeRoot) { + return createRequire(join(nativeRoot, "bridge-native.cjs"))( + join(nativeRoot, "node_modules", "koffi", "index.cjs") + ); + } + return (await import("koffi")).default; +} +async function createWindowsApi() { + if (process.platform !== "win32") { + throw new Error("Native Codex window activation is available on Windows only"); + } + const koffi = await loadKoffi(); + const user32 = koffi.load("user32.dll"); + const kernel32 = koffi.load("kernel32.dll"); + const HANDLE = koffi.pointer("HANDLE", koffi.opaque()); + koffi.alias("HWND", HANDLE); + const FLASHWINFO = koffi.struct("FLASHWINFO", { + cbSize: "uint32_t", + hwnd: "HWND", + dwFlags: "uint32_t", + uCount: "uint32_t", + dwTimeout: "uint32_t" + }); + const findWindowEx = user32.func( + "HWND __stdcall FindWindowExW(HWND parent, HWND childAfter, const char16_t *className, const char16_t *windowName)" + ); + const getWindowThreadProcessId = user32.func( + "uint32_t __stdcall GetWindowThreadProcessId(HWND hwnd, _Out_ uint32_t *processId)" + ); + const isWindow = user32.func("bool __stdcall IsWindow(HWND hwnd)"); + const isWindowVisible = user32.func("bool __stdcall IsWindowVisible(HWND hwnd)"); + const getWindowTextLength = user32.func("int __stdcall GetWindowTextLengthW(HWND hwnd)"); + const isZoomed = user32.func("bool __stdcall IsZoomed(HWND hwnd)"); + const showWindowAsync = user32.func("bool __stdcall ShowWindowAsync(HWND hwnd, int command)"); + const setForegroundWindow = user32.func("bool __stdcall SetForegroundWindow(HWND hwnd)"); + const getForegroundWindow = user32.func("HWND __stdcall GetForegroundWindow()"); + const bringWindowToTop = user32.func("bool __stdcall BringWindowToTop(HWND hwnd)"); + const setFocus = user32.func("HWND __stdcall SetFocus(HWND hwnd)"); + const attachThreadInput = user32.func( + "bool __stdcall AttachThreadInput(uint32_t sourceThreadId, uint32_t targetThreadId, bool attach)" + ); + const getCurrentThreadId = kernel32.func("uint32_t __stdcall GetCurrentThreadId()"); + const flashWindowEx = user32.func("bool __stdcall FlashWindowEx(_Inout_ FLASHWINFO *info)"); + function identityForWindow(hwnd) { + const processId = [0]; + const threadId = getWindowThreadProcessId(hwnd, processId); + return { + processId: Number(processId[0]) || 0, + threadId: Number(threadId) || 0 + }; + } + return { + isWindow, + isZoomed, + getForegroundWindow, + processIdForWindow(hwnd) { + return identityForWindow(hwnd).processId; + }, + findWindowForProcess(processId) { + let previous = null; + for (let index = 0; index < 4096; index += 1) { + const hwnd = findWindowEx(null, previous, null, null); + if (hwnd == null) return null; + previous = hwnd; + if (identityForWindow(hwnd).processId === processId && isWindow(hwnd) && isWindowVisible(hwnd) && getWindowTextLength(hwnd) > 0) { + return hwnd; + } + } + throw new Error("Windows window enumeration exceeded its safety bound"); + }, + maximize(hwnd) { + return showWindowAsync(hwnd, SW_MAXIMIZE); + }, + foreground(hwnd) { + return setForegroundWindow(hwnd); + }, + forceForeground(hwnd) { + const currentThreadId = Number(getCurrentThreadId()) || 0; + const foregroundWindow = getForegroundWindow(); + const foregroundThreadId = foregroundWindow == null ? 0 : identityForWindow(foregroundWindow).threadId; + const targetThreadId = identityForWindow(hwnd).threadId; + const attached = []; + const attach = (threadId) => { + if (threadId > 0 && threadId !== currentThreadId && !attached.includes(threadId) && attachThreadInput(currentThreadId, threadId, true)) { + attached.push(threadId); + } + }; + try { + attach(foregroundThreadId); + attach(targetThreadId); + bringWindowToTop(hwnd); + setForegroundWindow(hwnd); + setFocus(hwnd); + } finally { + for (const threadId of attached.reverse()) { + attachThreadInput(currentThreadId, threadId, false); + } + } + }, + flash(hwnd) { + const info = { + cbSize: koffi.sizeof(FLASHWINFO), + hwnd, + dwFlags: FLASHW_TRAY | FLASHW_TIMERNOFG, + uCount: 3, + dwTimeout: 0 + }; + flashWindowEx(info); + } + }; +} +async function defaultWindowsApi() { + if (!windowsApiPromise) windowsApiPromise = createWindowsApi(); + try { + return await windowsApiPromise; + } catch (error) { + windowsApiPromise = null; + throw error; + } +} +async function initializeWindowsFocusRuntime() { + await defaultWindowsApi(); + return true; +} +function validCachedWindow(api, processId) { + const hwnd = cachedWindows.get(processId); + if (hwnd != null && api.isWindow(hwnd) && api.processIdForWindow(hwnd) === processId) { + return hwnd; + } + cachedWindows.delete(processId); + return null; +} +async function activateWindowsProcess(processId, { + api, + wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) +} = {}) { + if (!Number.isInteger(processId) || processId <= 0) { + throw new Error("The connected Codex process identity is unavailable"); + } + const native = api ?? await defaultWindowsApi(); + const hwnd = validCachedWindow(native, processId) ?? native.findWindowForProcess(processId); + if (hwnd == null) throw new Error("The connected Codex Desktop window was not found"); + cachedWindows.set(processId, hwnd); + const alreadyForeground = sameWindow(native.getForegroundWindow(), hwnd); + const alreadyMaximized = Boolean(native.isZoomed(hwnd)); + if (!alreadyMaximized && !native.maximize(hwnd)) { + throw new Error("Windows did not accept the Codex maximize request"); + } + if (!alreadyForeground) native.foreground(hwnd); + if (!alreadyMaximized) await wait(20); + let foreground = sameWindow(native.getForegroundWindow(), hwnd); + if (!foreground && !alreadyForeground) { + await wait(25); + foreground = sameWindow(native.getForegroundWindow(), hwnd); + } + if (!foreground && !alreadyForeground && typeof native.forceForeground === "function") { + native.forceForeground(hwnd); + await wait(25); + foreground = sameWindow(native.getForegroundWindow(), hwnd); + } + if (!foreground) { + try { + native.flash(hwnd); + } catch { + } + throw new Error("Windows did not allow Codex Desktop to receive focus"); + } + return { + processId, + alreadyForeground, + alreadyMaximized, + maximized: alreadyMaximized || Boolean(native.isZoomed(hwnd)) + }; +} +function clearWindowsFocusCache() { + cachedWindows.clear(); +} +var SW_MAXIMIZE, FLASHW_TRAY, FLASHW_TIMERNOFG, cachedWindows, windowsApiPromise; +var init_windows_focus = __esm({ + "../../src/bridge/windows-focus.mjs"() { + SW_MAXIMIZE = 3; + FLASHW_TRAY = 2; + FLASHW_TIMERNOFG = 12; + cachedWindows = /* @__PURE__ */ new Map(); + windowsApiPromise = null; + } +}); + // ../../node_modules/ws/lib/constants.js var require_constants = __commonJS({ "../../node_modules/ws/lib/constants.js"(exports, module) { @@ -308,7 +517,7 @@ var require_permessage_deflate = __commonJS({ acceptAsServer(offers) { const opts = this._options; const accepted = offers.find((params) => { - if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && (typeof params.client_max_window_bits === "number" ? opts.clientMaxWindowBits > params.client_max_window_bits : !params.client_max_window_bits)) { + if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) { return false; } return true; @@ -2270,7 +2479,7 @@ var require_websocket = __commonJS({ var http = __require("http"); var net = __require("net"); var tls = __require("tls"); - var { randomBytes, createHash } = __require("crypto"); + var { randomBytes, createHash: createHash2 } = __require("crypto"); var { Duplex, Readable } = __require("stream"); var { URL: URL2 } = __require("url"); var PerMessageDeflate2 = require_permessage_deflate(); @@ -2938,7 +3147,7 @@ var require_websocket = __commonJS({ abortHandshake(websocket, socket, "Invalid Upgrade header"); return; } - const digest = createHash("sha1").update(key + GUID).digest("base64"); + const digest = createHash2("sha1").update(key + GUID).digest("base64"); if (res.headers["sec-websocket-accept"] !== digest) { abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header"); return; @@ -3307,7 +3516,7 @@ var require_websocket_server = __commonJS({ var EventEmitter = __require("events"); var http = __require("http"); var { Duplex } = __require("stream"); - var { createHash } = __require("crypto"); + var { createHash: createHash2 } = __require("crypto"); var extension2 = require_extension(); var PerMessageDeflate2 = require_permessage_deflate(); var subprotocol2 = require_subprotocol(); @@ -3614,7 +3823,7 @@ var require_websocket_server = __commonJS({ ); } if (this._state > RUNNING) return abortHandshake(socket, 503); - const digest = createHash("sha1").update(key + GUID).digest("base64"); + const digest = createHash2("sha1").update(key + GUID).digest("base64"); const headers = [ "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", @@ -3703,12 +3912,190 @@ var require_websocket_server = __commonJS({ // ../../src/bridge/server.mjs import { createServer } from "node:http"; -import { execFile as execFile2 } from "node:child_process"; -import { promisify as promisify2 } from "node:util"; +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join as join2 } from "node:path"; -// ../../src/bridge/codex-cdp.mjs -import { execFile } from "node:child_process"; +// ../../src/bridge/platform.mjs +import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; +var execFileAsync = promisify(execFile); +var CDP_HOST = "127.0.0.1"; +var DEFAULT_CDP_PORT = 9222; +var CDP_ARGUMENTS = Object.freeze([ + `--remote-debugging-address=${CDP_HOST}`, + `--remote-debugging-port=${DEFAULT_CDP_PORT}`, + `--remote-allow-origins=http://${CDP_HOST}:${DEFAULT_CDP_PORT}` +]); +var WINDOWS_PROCESS_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$listenerOwners = @{} +Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalAddress -eq '127.0.0.1' } | + ForEach-Object { $listenerOwners[('{0}:{1}' -f $_.LocalPort, $_.OwningProcess)] = $true } +$rows = Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -like '*--remote-debugging-address=127.0.0.1*' } | + ForEach-Object { + $portMatch = [regex]::Match([string]$_.CommandLine, '--remote-debugging-port(?:=|\s+)(\d+)') + $debugPort = if ($portMatch.Success) { [int]$portMatch.Groups[1].Value } else { 0 } + [pscustomobject]@{ + processId = [int]$_.ProcessId + executable = [string]$_.ExecutablePath + commandLine = [string]$_.CommandLine + ownsDebugPort = $debugPort -gt 0 -and $listenerOwners.ContainsKey(('{0}:{1}' -f $debugPort, $_.ProcessId)) + } + } +@($rows) | ConvertTo-Json -Compress +`; +var WINDOWS_PACKAGE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$rows = foreach ($name in @('OpenAI.Codex', 'OpenAI.CodexBeta')) { + $package = Get-AppxPackage -Name $name | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $package) { continue } + $app = Join-Path $package.InstallLocation 'app' + $names = if ($name -eq 'OpenAI.CodexBeta') { + @('ChatGPT (Beta).exe', 'Codex (Beta).exe', 'ChatGPT.exe') + } else { + @('ChatGPT.exe', 'Codex.exe') + } + foreach ($file in $names) { + $candidate = Join-Path $app $file + if (Test-Path -LiteralPath $candidate) { + [pscustomobject]@{ + channel = if ($name -eq 'OpenAI.CodexBeta') { 'beta' } else { 'stable' } + packageName = $name + packageFullName = $package.PackageFullName + executable = $candidate + } + break + } + } +} +@($rows) | ConvertTo-Json -Compress +`; +var WINDOWS_STOP_EXECUTABLE_COMMAND = String.raw` +$ErrorActionPreference = 'Stop' +$target = $env:CODEX_BRIDGE_TARGET_EXECUTABLE +$processes = @(Get-CimInstance Win32_Process | + Where-Object { + $_.ExecutablePath -eq $target -and + $_.CommandLine -notlike '*--type=*' -and + $_.CommandLine -notlike '*crashpad-handler*' + }) +foreach ($process in $processes) { Stop-Process -Id $process.ProcessId -ErrorAction Stop } +foreach ($process in $processes) { Wait-Process -Id $process.ProcessId -Timeout 8 -ErrorAction SilentlyContinue } +`; +function powershellArgs(command, extra = []) { + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + command, + ...extra + ]; +} +function powershellOptions(options = {}) { + return { ...options, windowsHide: true }; +} +function processChannel(executable, commandLine) { + const identity = `${executable || ""} ${commandLine || ""}`.toLowerCase(); + return /codexbeta|chatgpt\s*\(beta\)|codex\s*\(beta\)/.test(identity) ? "beta" : "stable"; +} +function debugProcessesFromCommandLines(text) { + const source = String(text || "").trim(); + let rows = []; + if (source.startsWith("[") || source.startsWith("{")) { + try { + const parsed = JSON.parse(source); + rows = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + } + } + if (rows.length === 0) { + rows = source.split(/\r?\n/).filter(Boolean).map((commandLine) => ({ commandLine })); + } + return rows.flatMap((row) => { + const commandLine = String(row?.commandLine || ""); + if (!commandLine.includes("--remote-debugging-address=127.0.0.1")) return []; + if (commandLine.includes("--type=")) return []; + const port = Number(commandLine.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); + if (!Number.isInteger(port) || port <= 0 || port > 65535) return []; + const processId = Number(row?.processId); + const executable = typeof row?.executable === "string" ? row.executable : null; + return [{ + port, + processId: Number.isInteger(processId) && processId > 0 ? processId : null, + executable, + channel: processChannel(executable, commandLine), + ownsDebugPort: row?.ownsDebugPort === true + }]; + }); +} +async function fetchJson(url, timeout = 1200, fetchImpl = fetch) { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); + return response.json(); +} +async function processCommandLines(platform, execute) { + if (platform === "win32") { + return (await execute( + "powershell.exe", + powershellArgs(WINDOWS_PROCESS_COMMAND), + powershellOptions({ timeout: 4e3 }) + )).stdout; + } + if (platform === "darwin") { + return (await execute("/bin/ps", ["-axo", "command="], { timeout: 4e3 })).stdout; + } + return ""; +} +async function discoverDebugEndpoint({ + platform = process.platform, + execute = execFileAsync, + fetchImpl = fetch, + preferredPort = DEFAULT_CDP_PORT +} = {}) { + let processes = []; + try { + processes = debugProcessesFromCommandLines(await processCommandLines(platform, execute)); + } catch { + } + const candidates = [preferredPort, ...processes.map((item) => item.port)]; + for (const port of [...new Set(candidates)]) { + try { + await fetchJson(`http://${CDP_HOST}:${port}/json/version`, 500, fetchImpl); + const process2 = processes.find((item) => item.port === port && item.ownsDebugPort) ?? processes.find((item) => item.port === port); + return { + port, + processId: process2?.processId ?? null, + executable: process2?.executable ?? null, + channel: process2?.channel ?? null + }; + } catch { + } + } + throw new Error("Codex is not running with the local debug bridge"); +} +async function focusCodex({ + platform = process.platform, + execute = execFileAsync, + processId = null, + activateWindows = null +} = {}) { + if (platform === "win32") { + const activate = activateWindows ?? (await Promise.resolve().then(() => (init_windows_focus(), windows_focus_exports))).activateWindowsProcess; + return activate(processId); + } + if (platform === "darwin") { + await execute("/usr/bin/open", ["-b", "com.openai.codex"], { timeout: 3e3 }); + return; + } + throw new Error(`Codex Desktop focus is not supported on ${platform}`); +} // ../../node_modules/ws/wrapper.mjs var import_stream = __toESM(require_stream(), 1); @@ -3748,7 +4135,6 @@ function localThreadKey(value) { } // ../../src/bridge/codex-cdp.mjs -var execFileAsync = promisify(execFile); var USAGE_REFRESH_MS = Math.max( 15e3, Number(process.env.CODEX_KEYBOARD_USAGE_REFRESH_SECONDS || 600) * 1e3 @@ -3765,7 +4151,12 @@ var MICRO_ACTION_KEYS = Object.freeze({ mic: "ACT10", submit: "ACT12" }); -var RENDERER_ACTIONS = /* @__PURE__ */ new Set(["pin", "new"]); +var MODEL_PRESETS = Object.freeze({ + "model-sol-high": Object.freeze({ model: "gpt-5.6-sol", displayName: "5.6 Sol", effort: "high" }), + "model-luna-max": Object.freeze({ model: "gpt-5.6-luna", displayName: "5.6 Luna", effort: "max" }), + "model-sol-medium": Object.freeze({ model: "gpt-5.6-sol", displayName: "5.6 Sol", effort: "medium" }) +}); +var RENDERER_ACTIONS = /* @__PURE__ */ new Set(["pin", "new", ...Object.keys(MODEL_PRESETS)]); var PIN_ACTION_LABELS = Object.freeze([ "Pin chat", "Unpin chat", @@ -4089,49 +4480,124 @@ function selectMainTarget(targets) { } }) ?? pages.find((target) => !/avatar-overlay|composition-surface/i.test(target.url || "")); } -async function fetchJson(url, timeout = 1200) { - const response = await fetch(url, { signal: AbortSignal.timeout(timeout) }); - if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); - return response.json(); +function traceErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timed out") || message.includes("timeout")) return "timeout"; + if (message.includes("disconnected") || message.includes("not running")) return "cdp-unavailable"; + if (message.includes("window")) return "focus-failed"; + if (message.includes("menu") || message.includes("model") || message.includes("reasoning")) return "renderer-state"; + return "cdp-operation"; } -async function discoverDebugPort() { - const { stdout } = await execFileAsync("/bin/ps", ["-axo", "command="], { timeout: 4e3 }); - for (const line of stdout.split("\n")) { - if (!line.includes("--remote-debugging-address=127.0.0.1")) continue; - const port = Number(line.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1]); - if (!Number.isInteger(port)) continue; - try { - await fetchJson(`http://127.0.0.1:${port}/json/version`, 500); - return port; - } catch { - } +async function runTraceStage(trace, stage, operation, fields = {}) { + const startedAt = performance.now(); + trace?.record("cdp.stage", { stage, outcome: "started", ...fields }); + try { + const result = await operation(); + trace?.record("cdp.stage", { + stage, + outcome: "succeeded", + durationMs: Math.round(performance.now() - startedAt), + ...fields + }); + return result; + } catch (error) { + trace?.record("cdp.stage", { + stage, + outcome: "failed", + category: traceErrorCategory(error), + durationMs: Math.round(performance.now() - startedAt), + ...fields + }); + throw error; } - throw new Error("Codex is not running with the local debug bridge"); } var CodexCdpClient = class { socket = null; + connectPromise = null; + connectionGeneration = 0; nextId = 0; pending = /* @__PURE__ */ new Map(); lastSnapshot = null; + connectionIdentity = null; + modelActionQueue = Promise.resolve(); + modelPickerLayout = null; + constructor({ + discoverPort = discoverDebugEndpoint, + fetchTargets = fetchJson, + createSocket = (url) => new wrapper_default(url) + } = {}) { + this.discoverPort = discoverPort; + this.fetchTargets = fetchTargets; + this.createSocket = createSocket; + } async connect() { if (this.socket?.readyState === wrapper_default.OPEN) return; - const port = await discoverDebugPort(); - const target = selectMainTarget(await fetchJson(`http://127.0.0.1:${port}/json/list`)); + if (this.connectPromise) return this.connectPromise; + const generation = ++this.connectionGeneration; + const operation = this.openConnection(generation); + this.connectPromise = operation; + try { + return await operation; + } finally { + if (this.connectPromise === operation) this.connectPromise = null; + } + } + async openConnection(generation) { + const discovery = await this.discoverPort(); + const identity = Number.isInteger(discovery) ? { port: discovery, processId: null, executable: null, channel: null } : discovery; + const port = Number(identity?.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error("Codex debug endpoint identity was invalid"); + } + const target = selectMainTarget(await this.fetchTargets(`http://127.0.0.1:${port}/json/list`)); if (!target?.webSocketDebuggerUrl) throw new Error("Codex main renderer was not found"); - const socket = new wrapper_default(target.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { - const timer2 = setTimeout(() => reject(new Error("Timed out connecting to Codex")), 3e3); - socket.once("open", () => { - clearTimeout(timer2); - resolve(); - }); - socket.once("error", reject); - }); - socket.on("message", (raw) => this.handleMessage(String(raw))); - socket.on("close", () => this.disconnect()); - socket.on("error", () => this.disconnect()); + if (generation !== this.connectionGeneration) throw new Error("Codex bridge connection was cancelled"); + const socket = this.createSocket(target.webSocketDebuggerUrl); this.socket = socket; - await this.evaluate(ENABLE_EXPRESSION); + this.connectionIdentity = { + port, + processId: Number.isInteger(identity?.processId) ? identity.processId : null, + executable: typeof identity?.executable === "string" ? identity.executable : null, + channel: ["stable", "beta"].includes(identity?.channel) ? identity.channel : null + }; + try { + await new Promise((resolve, reject) => { + const timer2 = setTimeout(() => { + cleanup(); + reject(new Error("Timed out connecting to Codex")); + }, 3e3); + const cleanup = () => { + clearTimeout(timer2); + socket.off("open", onOpen); + socket.off("error", onError); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + socket.once("open", onOpen); + socket.once("error", onError); + }); + if (generation !== this.connectionGeneration || this.socket !== socket) { + throw new Error("Codex bridge connection was cancelled"); + } + socket.on("message", (raw) => this.handleMessage(String(raw))); + socket.on("close", () => { + if (this.socket === socket) this.disconnect(); + }); + socket.on("error", () => { + if (this.socket === socket) this.disconnect(); + }); + await this.evaluate(ENABLE_EXPRESSION); + } catch (error) { + if (this.socket === socket) this.disconnect(); + throw error; + } } async snapshot() { await this.connect(); @@ -4150,21 +4616,55 @@ var CodexCdpClient = class { if (!agent?.threadKey) throw new Error(`Agent slot ${slot + 1} is empty`); return this.clickThreadKey(agent.threadKey, slot); } - async clickThread(threadId, slot = 0) { - await this.connect(); - return this.clickThreadKey(localThreadKey(threadId), slot); + async clickThread(threadId, slot = 0, trace = null) { + await runTraceStage(trace, "task.connect", () => this.connect(), { slot: slot + 1 }); + return this.clickThreadKey(localThreadKey(threadId), slot, trace); } - async clickThreadKey(threadKey, slot) { + async clickThreadKey(threadKey, slot, trace = null) { try { - await this.dispatchAgent(slot, threadKey, 1); + await runTraceStage( + trace, + "task.native-act1", + () => this.dispatchAgent(slot, threadKey, 1), + { slot: slot + 1 } + ); + const finishBackground = trace?.defer?.(); + trace?.record("task.background", { stage: "scheduled", background: true, slot: slot + 1 }); void (async () => { await new Promise((resolve) => setTimeout(resolve, 35)); - await this.dispatchAgent(slot, threadKey, 0); - await this.activateThread(threadKey); - })().catch(() => { + await runTraceStage( + trace, + "task.native-act0", + () => this.dispatchAgent(slot, threadKey, 0), + { background: true, slot: slot + 1 } + ); + await runTraceStage( + trace, + "task.dom-activate", + () => this.activateThread(threadKey), + { background: true, slot: slot + 1 } + ); + })().catch((error) => { + trace?.record("task.background", { + stage: "complete", + background: true, + outcome: "failed", + category: traceErrorCategory(error), + slot: slot + 1 + }); + }).finally(() => finishBackground?.()); + } catch (error) { + trace?.record("task.fallback", { + outcome: "started", + category: traceErrorCategory(error), + slot: slot + 1 }); - } catch { - await this.activateThread(threadKey); + await runTraceStage( + trace, + "task.dom-activate-fallback", + () => this.activateThread(threadKey), + { slot: slot + 1 } + ); } } async dispatchAgent(slot, threadKey, act) { @@ -4179,21 +4679,330 @@ var CodexCdpClient = class { event: { key, act, slot: null, threadKey: null } }, "codex-micro-hid-event"); } - async dispatchNamedAction(action, pressed) { + async dispatchNamedAction(action, pressed, trace = null) { const key = MICRO_ACTION_KEYS[action]; if (key) return this.dispatchAction(key, pressed ? 1 : 0); if (!RENDERER_ACTIONS.has(action)) { throw new Error(`Unsupported Codex bridge action: ${action}`); } if (!pressed) return true; - return this.dispatchRendererAction(action); + return this.dispatchRendererAction(action, trace); } - async dispatchRendererAction(action) { - await this.connect(); + async dispatchRendererAction(action, trace = null) { + if (MODEL_PRESETS[action]) { + const operation = this.modelActionQueue.then(async () => { + await runTraceStage(trace, "model.connect", () => this.connect(), { action }); + return this.dispatchModelPreset(action, trace); + }); + this.modelActionQueue = operation.catch(() => { + }); + await operation; + return true; + } + await runTraceStage(trace, "model.connect", () => this.connect(), { action }); const invoked = await this.evaluate(rendererActionExpression(action)); if (!invoked) throw new Error(`Codex ${action} action is not available`); return true; } + async dispatchModelPreset(action, trace = null) { + const preset = MODEL_PRESETS[action]; + if (!preset) throw new Error(`Unknown Codex model preset: ${action}`); + const presetStartedAt = performance.now(); + trace?.record("model.preset", { + action, + stage: "start", + outcome: "started", + targetEffort: preset.effort + }); + const effortOrder = ["low", "medium", "high", "xhigh", "max"]; + const targetEffortIndex = effortOrder.indexOf(preset.effort); + const readState = async () => { + const state = await runTraceStage(trace, "model.read-state", () => this.evaluate(`(() => { + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + const triggers = [...document.querySelectorAll("[data-codex-intelligence-trigger]")].filter(visible); + if (triggers.length !== 1) return { error: \`Expected one visible intelligence trigger, found \${triggers.length}\` }; + return { + text: String(triggers[0].textContent ?? "").replace(/\\s+/g, " ").trim(), + effort: triggers[0].getAttribute("data-selected-reasoning-effort"), + expanded: triggers[0].getAttribute("aria-expanded") === "true" + }; + })()`)); + if (state?.error) throw new Error(state.error); + trace?.record("model.state", { + currentEffort: state.effort || "unknown", + effortMatched: state.effort === preset.effort, + modelMatched: state.text.includes(preset.displayName) + }); + return state; + }; + const closeMenus = async () => { + let attempts = 0; + for (; attempts < 3 && (await readState()).expanded; attempts += 1) { + await this.pressRendererEscape(trace, "model.close-menu"); + } + if ((await readState()).expanded) throw new Error("Codex intelligence menu did not close"); + trace?.record("model.menu-close", { attempts, outcome: "succeeded" }); + }; + const openMain = async () => { + if (!(await readState()).expanded) { + await this.clickRendererCandidates( + '[...document.querySelectorAll("[data-codex-intelligence-trigger]")]', + "Codex intelligence trigger", + trace, + "model.open-trigger" + ); + } + await this.waitForRenderer(`(() => { + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + return [...document.querySelectorAll('[role="menu"][data-state="open"]')].filter( + (menu) => visible(menu) && ( + menu.querySelector("[data-model-picker-view-toggle]") || + menu.querySelector("[data-reasoning-slider]") + ) + ).length === 1; + })()`, "Codex intelligence menu", trace, "model.wait-main-menu"); + const toggleState = await runTraceStage(trace, "model.read-menu-shape", () => this.evaluate(`(() => { + const menus = [...document.querySelectorAll('[role="menu"][data-state="open"]')].filter( + (menu) => menu.querySelector("[data-model-picker-view-toggle]") || menu.querySelector("[data-reasoning-slider]") + ); + const toggles = menus.length === 1 + ? [...menus[0].querySelectorAll("[data-model-picker-view-toggle]")] + : []; + return { + count: toggles.length, + expanded: toggles[0]?.getAttribute("aria-expanded") === "true", + rowCount: menus[0]?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0 + }; + })()`)); + trace?.record("model.menu-shape", { + rowCount: toggleState.rowCount, + outcome: toggleState.expanded ? "expanded" : "collapsed" + }); + if (toggleState.count === 0 && toggleState.rowCount === 2) return; + if (toggleState.count !== 1) { + throw new Error(`Expected one model picker view toggle, found ${toggleState.count}`); + } + if (!toggleState.expanded) { + await this.clickRendererCandidates( + `(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return menu ? [...menu.querySelectorAll("[data-model-picker-view-toggle]")] : []; + })()`, + "model picker view toggle", + trace, + "model.open-picker-view" + ); + await this.waitForRenderer(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return menu?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length === 2; + })()`, "expanded Codex model picker", trace, "model.wait-picker-view"); + } + }; + const rowExpression = (rowIndex) => `(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + return rows[${rowIndex}] ? [rows[${rowIndex}]] : []; + })()`; + const submenuInfo = async (rowIndex) => this.waitForRenderer(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + if (rows.length !== 2 || !rows[${rowIndex}]) return null; + const submenu = document.getElementById(rows[${rowIndex}].getAttribute("aria-controls")); + if (!submenu || submenu.getAttribute("data-state") !== "open") return null; + return [...submenu.querySelectorAll('[role="menuitem"]')].map((item) => ({ + text: String(item.textContent ?? "").replace(/\\s+/g, " ").trim(), + checked: Boolean(item.querySelector("svg")) + })); + })()`, "Codex model picker submenu", trace, `model.wait-submenu-${rowIndex + 1}`); + const identifyRows = async () => { + if (this.modelPickerLayout?.connectionGeneration === this.connectionGeneration) { + trace?.record("model.rows-identified", { + rowCount: 2, + source: "cache", + outcome: "succeeded" + }); + return this.modelPickerLayout; + } + this.modelPickerLayout = null; + const rowCount = await runTraceStage(trace, "model.read-row-count", () => this.evaluate(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + return menu?.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]').length ?? 0; + })()`)); + if (rowCount !== 2) throw new Error(`Expected two Codex model picker rows, found ${rowCount}`); + let modelRowIndex = -1; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + await openMain(); + await this.clickRendererCandidates( + rowExpression(rowIndex), + `model picker row ${rowIndex + 1}`, + trace, + `model.open-row-${rowIndex + 1}` + ); + const items = await submenuInfo(rowIndex); + if (items.filter((item) => item.text === preset.displayName).length === 1) { + modelRowIndex = rowIndex; + } + await this.pressRendererEscape(trace, `model.close-row-${rowIndex + 1}`); + } + if (modelRowIndex < 0) throw new Error(`Codex model ${preset.displayName} is not available`); + const result = { + connectionGeneration: this.connectionGeneration, + modelRowIndex, + effortRowIndex: modelRowIndex === 0 ? 1 : 0 + }; + this.modelPickerLayout = result; + trace?.record("model.rows-identified", { + rowCount, + source: "probe", + outcome: "succeeded" + }); + return result; + }; + const selectEffort = async () => { + const current = await readState(); + if (current.effort === preset.effort) { + trace?.record("model.effort", { + currentEffort: current.effort, + targetEffort: preset.effort, + outcome: "skipped" + }); + return; + } + trace?.record("model.effort", { + currentEffort: current.effort || "unknown", + targetEffort: preset.effort, + outcome: "changing" + }); + const currentEffortIndex = effortOrder.indexOf(current.effort); + if (currentEffortIndex < 0 || targetEffortIndex < 0) { + throw new Error(`Unsupported Codex reasoning effort transition: ${current.effort} -> ${preset.effort}`); + } + await openMain(); + const { effortRowIndex } = await identifyRows(); + await openMain(); + await this.clickRendererCandidates( + rowExpression(effortRowIndex), + "reasoning effort row", + trace, + "model.open-effort-row" + ); + const items = await submenuInfo(effortRowIndex); + const checkedIndexes = items.flatMap((item, index) => item.checked ? [index] : []); + if (items.length !== effortOrder.length || checkedIndexes.length !== 1 || checkedIndexes[0] !== currentEffortIndex) { + throw new Error("Codex reasoning effort order or selected state changed"); + } + await this.clickRendererCandidates(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + const submenu = rows[${effortRowIndex}] + ? document.getElementById(rows[${effortRowIndex}].getAttribute("aria-controls")) + : null; + const items = submenu ? [...submenu.querySelectorAll('[role="menuitem"]')] : []; + return items[${targetEffortIndex}] ? [items[${targetEffortIndex}]] : []; + })()`, `reasoning effort ${preset.effort}`, trace, "model.select-effort-option"); + await this.waitForRenderer( + `document.querySelector("[data-codex-intelligence-trigger]")?.getAttribute("data-selected-reasoning-effort") === ${JSON.stringify(preset.effort)}`, + `reasoning effort ${preset.effort}`, + trace, + "model.wait-effort-selected" + ); + trace?.record("model.effort", { + targetEffort: preset.effort, + outcome: "succeeded" + }); + }; + const selectModel = async () => { + if ((await readState()).text.includes(preset.displayName)) { + trace?.record("model.model", { modelMatched: true, outcome: "skipped" }); + return; + } + trace?.record("model.model", { modelMatched: false, outcome: "changing" }); + await openMain(); + const { modelRowIndex } = await identifyRows(); + await openMain(); + await this.clickRendererCandidates( + rowExpression(modelRowIndex), + "model row", + trace, + "model.open-model-row" + ); + const items = await submenuInfo(modelRowIndex); + if (items.filter((item) => item.text === preset.displayName).length !== 1) { + throw new Error(`Expected one available ${preset.displayName} model option`); + } + await this.clickRendererCandidates(`(() => { + const menu = [...document.querySelectorAll('[role="menu"][data-state="open"]')].find( + (candidate) => candidate.querySelector("[data-model-picker-view-toggle]") || candidate.querySelector("[data-reasoning-slider]") + ); + const rows = menu ? [...menu.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')] : []; + const submenu = rows[${modelRowIndex}] + ? document.getElementById(rows[${modelRowIndex}].getAttribute("aria-controls")) + : null; + return submenu + ? [...submenu.querySelectorAll('[role="menuitem"]')].filter( + (item) => String(item.textContent ?? "").replace(/\\s+/g, " ").trim() === ${JSON.stringify(preset.displayName)} + ) + : []; + })()`, `model ${preset.displayName}`, trace, "model.select-model-option"); + await this.waitForRenderer( + `String(document.querySelector("[data-codex-intelligence-trigger]")?.textContent ?? "").includes(${JSON.stringify(preset.displayName)})`, + `model ${preset.displayName}`, + trace, + "model.wait-model-selected" + ); + trace?.record("model.model", { modelMatched: true, outcome: "succeeded" }); + }; + try { + await runTraceStage(trace, "model.select-model", selectModel, { action }); + await runTraceStage(trace, "model.select-effort", selectEffort, { targetEffort: preset.effort }); + const selected = await runTraceStage(trace, "model.validate", readState, { action }); + if (!selected.text.includes(preset.displayName) || selected.effort !== preset.effort) { + throw new Error(`Codex did not select ${preset.displayName} / ${preset.effort}`); + } + await runTraceStage(trace, "model.close-menus", closeMenus, { action }); + trace?.record("model.preset", { + action, + stage: "complete", + outcome: "succeeded", + targetEffort: preset.effort, + durationMs: Math.round(performance.now() - presetStartedAt) + }); + return { model: preset.model, effort: preset.effort }; + } catch (error) { + this.modelPickerLayout = null; + trace?.record("model.preset", { + action, + stage: "complete", + outcome: "failed", + category: traceErrorCategory(error), + targetEffort: preset.effort, + durationMs: Math.round(performance.now() - presetStartedAt) + }); + try { + await runTraceStage(trace, "model.cleanup-menus", closeMenus, { action }); + } catch { + } + throw error; + } + } async dispatchComposerSteer() { await this.connect(); const clicked = await this.evaluate(composerSteerExpression()); @@ -4268,7 +5077,7 @@ var CodexCdpClient = class { return true; })()`); } - evaluate(expression) { + sendCommand(method, params, returnValue = false) { if (!this.socket || this.socket.readyState !== wrapper_default.OPEN) { return Promise.reject(new Error("Codex bridge is disconnected")); } @@ -4278,14 +5087,77 @@ var CodexCdpClient = class { this.pending.delete(id); reject(new Error("Codex runtime response timed out")); }, 7e3); - this.pending.set(id, { resolve, reject, timer: timer2 }); + this.pending.set(id, { resolve, reject, timer: timer2, returnValue }); this.socket.send(JSON.stringify({ id, - method: "Runtime.evaluate", - params: { expression, awaitPromise: true, returnByValue: true } + method, + params })); }); } + evaluate(expression) { + return this.sendCommand("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true + }, true); + } + async clickRendererCandidates(candidatesExpression, description, trace = null, stage = "renderer.click") { + await runTraceStage(trace, stage, () => this.evaluate(`(() => { + const visible = (element) => { + const rect = element?.getBoundingClientRect?.(); + return element && (element.offsetParent !== null || (rect?.width > 0 && rect?.height > 0)); + }; + const candidates = [...(${candidatesExpression})].filter(visible); + if (candidates.length !== 1) { + throw new Error(\`Expected one ${description}, found \${candidates.length}\`); + } + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + const EventType = type.startsWith("pointer") ? PointerEvent : MouseEvent; + candidates[0].dispatchEvent(new EventType(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type.endsWith("down") ? 1 : 0, + view: window + })); + } + return true; + })()`)); + } + async pressRendererEscape(trace = null, stage = "renderer.escape") { + await runTraceStage(trace, stage, () => this.evaluate(`(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "Escape", code: "Escape", bubbles: true, cancelable: true + })); + return true; + })()`)); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + async waitForRenderer(expression, description, trace = null, stage = "renderer.wait") { + const startedAt = performance.now(); + for (let attempt = 0; attempt < 20; attempt += 1) { + const result = await this.evaluate(expression); + if (result) { + trace?.record("renderer.poll", { + stage, + outcome: "succeeded", + attempts: attempt + 1, + durationMs: Math.round(performance.now() - startedAt) + }); + return result; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + trace?.record("renderer.poll", { + stage, + outcome: "failed", + category: "timeout", + attempts: 20, + durationMs: Math.round(performance.now() - startedAt) + }); + throw new Error(`Timed out waiting for ${description}`); + } handleMessage(raw) { let message; try { @@ -4303,12 +5175,24 @@ var CodexCdpClient = class { message.result.exceptionDetails.exception?.description ?? message.result.exceptionDetails.text ?? "Codex evaluation failed" )); } - pending.resolve(message.result?.result?.value); + pending.resolve( + pending.returnValue ? message.result?.result?.value : message.result + ); } disconnect() { + this.connectionGeneration += 1; + this.connectPromise = null; + this.modelPickerLayout = null; + this.connectionIdentity = null; const socket = this.socket; this.socket = null; - if (socket?.readyState === wrapper_default.OPEN) socket.close(); + if (socket && socket.readyState !== wrapper_default.CLOSED) { + try { + socket.close(); + } catch { + socket.terminate?.(); + } + } for (const { reject, timer: timer2 } of this.pending.values()) { clearTimeout(timer2); reject(new Error("Codex bridge disconnected")); @@ -4317,13 +5201,46 @@ var CodexCdpClient = class { } }; +// ../../src/bridge/auth.mjs +import { timingSafeEqual } from "node:crypto"; +function bridgeRequestAuthorized(token, authorizationHeader) { + if (!token) return false; + const candidate = String(authorizationHeader || "").replace(/^Bearer\s+/i, ""); + const actual = Buffer.from(token); + const supplied = Buffer.from(candidate); + return actual.length === supplied.length && timingSafeEqual(actual, supplied); +} + +// ../../src/bridge/navigation.mjs +async function navigateAndFocus(navigate, focus) { + const [navigation, activation] = await Promise.allSettled([navigate(), focus()]); + if (navigation.status === "rejected") throw navigation.reason; + return { focusOk: activation.status === "fulfilled" }; +} + // ../../src/bridge/server.mjs -var execFileAsync2 = promisify2(execFile2); var HOST = "127.0.0.1"; var PORT = Number(process.env.CODEX_KEYBOARD_PORT || 17373); +var BRIDGE_VERSION = false ? null : "0.6.1"; +var RUNTIME_HASH = (() => { + try { + return createHash("sha256").update(readFileSync(process.argv[1])).digest("hex"); + } catch { + return null; + } +})(); +var NATIVE_RUNTIME_HASH = /^[a-f0-9]{64}$/.test(String(process.env.CODEX_BRIDGE_NATIVE_HASH || "")) ? process.env.CODEX_BRIDGE_NATIVE_HASH : null; var configuredRefreshMs = Number(process.env.CODEX_KEYBOARD_REFRESH_MS || 500); var REFRESH_MS = Number.isFinite(configuredRefreshMs) ? Math.max(250, configuredRefreshMs) : 500; var client = new CodexCdpClient(); +var dataRoot = process.env.CODEX_BRIDGE_DATA_ROOT || (process.platform === "win32" ? join2(process.env.LOCALAPPDATA || join2(homedir(), "AppData", "Local"), "OpenCodexMicro") : join2(homedir(), "Library", "Application Support", "OpenCodexMicro")); +var bridgeToken = process.env.CODEX_BRIDGE_TOKEN || (() => { + try { + return readFileSync(join2(dataRoot, "bridge-token"), "utf8").trim(); + } catch { + return ""; + } +})(); var cached = { connected: false, slots: Array.from({ length: 6 }, (_, id) => ({ @@ -4338,10 +5255,125 @@ var cached = { }; var refreshPromise = null; var nextReconnectAt = 0; -async function focusCodex() { - await execFileAsync2("/usr/bin/open", ["-b", "com.openai.codex"], { - timeout: 3e3 - }); +var hasRefreshed = false; +var traceBuffer = /* @__PURE__ */ new Map(); +var TRACE_TTL_MS = 3e4; +var TRACE_LIMIT = 32; +var TRACE_EVENT_LIMIT = 160; +function diagnosticErrorCategory(error) { + const name = String(error?.name || "").toLowerCase(); + const message = String(error?.message || "").toLowerCase(); + if (name.includes("timeout") || message.includes("timed out") || message.includes("timeout")) return "timeout"; + if (message.includes("disconnected") || message.includes("not running")) return "cdp-unavailable"; + if (message.includes("window")) return "focus-failed"; + if (message.includes("menu") || message.includes("model") || message.includes("reasoning")) return "renderer-state"; + return "bridge-operation"; +} +function traceIdFrom(request) { + const value = String(request.headers["x-codex-trace-id"] || ""); + return /^[a-f0-9-]{36}$/.test(value) ? value : null; +} +function safeTraceFields(fields) { + return Object.fromEntries(Object.entries(fields || {}).flatMap(([key, value]) => { + if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) { + return [[key, value]]; + } + const normalized = String(value || ""); + return /^[a-zA-Z0-9_.:-]{1,80}$/.test(normalized) ? [[key, normalized]] : []; + })); +} +function startTrace(request, route, fields = {}) { + const traceId = traceIdFrom(request); + if (!traceId) return null; + const startedAt = performance.now(); + const trace = { + traceId, + startedAt, + events: [], + pending: 0, + completionRequested: false, + complete: false, + record(event, details = {}) { + if (!/^[a-z0-9.-]{1,80}$/.test(event) || this.events.length >= TRACE_EVENT_LIMIT) return; + this.events.push({ + event, + offsetMs: Math.round(performance.now() - startedAt), + ...safeTraceFields(details) + }); + }, + defer() { + this.pending += 1; + let settled = false; + return () => { + if (settled) return; + settled = true; + this.pending = Math.max(0, this.pending - 1); + if (this.completionRequested && this.pending === 0) this.complete = true; + }; + }, + finish() { + this.completionRequested = true; + if (this.pending === 0) this.complete = true; + } + }; + trace.record("server.request", { route, ...fields }); + traceBuffer.set(traceId, trace); + while (traceBuffer.size > TRACE_LIMIT) traceBuffer.delete(traceBuffer.keys().next().value); + const cleanup = setTimeout(() => traceBuffer.delete(traceId), TRACE_TTL_MS); + cleanup.unref?.(); + return trace; +} +function traceSnapshot(traceId) { + const trace = traceBuffer.get(traceId); + if (!trace) return null; + return { + traceId, + complete: trace.complete, + events: trace.events.map((event) => ({ ...event })) + }; +} +async function focusCodexDesktop(trace = null) { + const startedAt = performance.now(); + const path = process.platform === "win32" ? "win32-native" : "platform-adapter"; + trace?.record("focus.start", { platform: process.platform, path }); + try { + let identity = null; + if (process.platform === "win32") { + const connection = client.socket?.readyState === 1 ? "reused" : "open"; + await client.connect(); + identity = client.connectionIdentity; + trace?.record("focus.target", { + connection, + channel: identity?.channel || "unknown", + outcome: "succeeded" + }); + } + const result = await focusCodex({ processId: identity?.processId ?? null }); + trace?.record("focus.native", { + outcome: "succeeded", + channel: identity?.channel || "unknown", + reused: Boolean(result?.alreadyForeground && result?.alreadyMaximized) + }); + trace?.record("focus.complete", { + platform: process.platform, + path, + outcome: "succeeded", + durationMs: Math.round(performance.now() - startedAt) + }); + console.log("Codex Desktop window maximized and focused through the native desktop adapter"); + } catch (error) { + trace?.record("focus.complete", { + platform: process.platform, + path, + outcome: "failed", + category: diagnosticErrorCategory(error), + durationMs: Math.round(performance.now() - startedAt) + }); + throw error; + } +} +function authorized(request) { + return bridgeRequestAuthorized(bridgeToken, request.headers.authorization); } async function refresh(force = false) { if (refreshPromise) return refreshPromise; @@ -4359,6 +5391,7 @@ async function refresh(force = false) { try { await refreshPromise; } finally { + hasRefreshed = true; refreshPromise = null; } } @@ -4373,16 +5406,33 @@ function json(response, status, body) { } var server = createServer(async (request, response) => { const url = new URL(request.url || "/", `http://${HOST}:${PORT}`); + if (request.method === "POST" && !authorized(request)) { + return json(response, 401, { ok: false, error: "Bridge authorization required" }); + } if (request.method === "GET" && url.pathname === "/health") { - await refresh(true); - return json(response, 200, { ok: true, codexConnected: cached.connected, updatedAt: cached.updatedAt }); + if (!hasRefreshed || Date.now() - cached.updatedAt >= REFRESH_MS * 2) { + await refresh(true); + } + return json(response, 200, { + ok: true, + bridgeVersion: BRIDGE_VERSION, + runtimeHash: RUNTIME_HASH, + nativeRuntimeHash: NATIVE_RUNTIME_HASH, + codexConnected: cached.connected, + updatedAt: cached.updatedAt + }); } if (request.method === "GET" && url.pathname === "/state") { return json(response, 200, cached); } + if (request.method === "POST" && url.pathname === "/diagnostics/trace") { + const traceId = traceIdFrom(request); + const diagnostics = traceId ? traceSnapshot(traceId) : null; + return json(response, diagnostics ? 200 : 404, diagnostics ? { ok: true, diagnostics } : { ok: false, error: "Trace diagnostics are unavailable" }); + } if (request.method === "POST" && url.pathname === "/focus") { try { - await focusCodex(); + await focusCodexDesktop(); return json(response, 200, { ok: true }); } catch (error) { return json(response, 503, { ok: false, error: error.message }); @@ -4391,11 +5441,11 @@ var server = createServer(async (request, response) => { const match = request.method === "POST" && url.pathname.match(/^\/agent\/([0-5])\/click$/); if (match) { try { - await Promise.all([ - client.clickAgent(Number(match[1])), - focusCodex() - ]); - return json(response, 200, { ok: true }); + const result = await navigateAndFocus( + () => client.clickAgent(Number(match[1])), + () => focusCodexDesktop() + ); + return json(response, 200, { ok: true, ...result }); } catch (error) { return json(response, 503, { ok: false, error: error.message }); } @@ -4404,41 +5454,64 @@ var server = createServer(async (request, response) => { /^\/thread\/([^/]+)\/click$/ ); if (threadMatch) { + let trace = null; try { const threadId = decodeThreadPathSegment(threadMatch[1]); const slot = Number(url.searchParams.get("slot") || 0); if (!Number.isInteger(slot) || slot < 0 || slot > 5) { throw new Error("Invalid Codex Micro slot"); } - await Promise.all([ - client.clickThread(threadId, slot), - focusCodex() - ]); - return json(response, 200, { ok: true, bridge: true }); + trace = startTrace(request, "thread-click", { slot: slot + 1 }); + const result = await navigateAndFocus( + () => client.clickThread(threadId, slot, trace), + () => focusCodexDesktop(trace) + ); + trace?.record("server.response", { + outcome: "succeeded", + route: "thread-click", + focusOk: result.focusOk + }); + return json(response, 200, { ok: true, bridge: true, ...result }); } catch (error) { + trace?.record("server.response", { + outcome: "failed", + route: "thread-click", + category: diagnosticErrorCategory(error) + }); return json(response, 503, { ok: false, bridge: false, error: error.message }); + } finally { + trace?.finish(); } } const action = request.method === "POST" && url.pathname.match( - /^\/action\/(fast|approve|reject|pin|new|fork|mic|steer|submit)\/(down|up)$/ + /^\/action\/(fast|approve|reject|pin|new|fork|mic|steer|submit|model-sol-high|model-luna-max|model-sol-medium)\/(down|up)$/ ); if (action) { + const trace = startTrace(request, "action", { action: action[1], phase: action[2] }); try { if (action[1] === "steer") { if (action[2] === "down") { - await focusCodex(); + await focusCodexDesktop(); await client.dispatchComposerSteer(); } return json(response, 200, { ok: true }); } - await client.dispatchNamedAction(action[1], action[2] === "down"); + await client.dispatchNamedAction(action[1], action[2] === "down", trace); + trace?.record("server.response", { outcome: "succeeded", route: "action" }); return json(response, 200, { ok: true, bridge: true }); } catch (error) { + trace?.record("server.response", { + outcome: "failed", + route: "action", + category: diagnosticErrorCategory(error) + }); return json(response, 503, { ok: false, error: error.message }); + } finally { + trace?.finish(); } } const joystick = request.method === "POST" && url.pathname.match( @@ -4455,7 +5528,7 @@ var server = createServer(async (request, response) => { return json(response, 404, { ok: false, error: "Not found" }); }); server.listen(PORT, HOST, () => { - console.log(`Codex Keyboard bridge listening on http://${HOST}:${PORT}`); + console.log(`Codex Keyboard bridge ${BRIDGE_VERSION || "unversioned"} listening on http://${HOST}:${PORT}`); void refresh(); }); var timer = setInterval(() => void refresh(), REFRESH_MS); diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json index 3b8204d..3c181ff 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/manifest.json @@ -2,7 +2,7 @@ "Author": "OpenCodexMicro", "Name": "Codex Micro", "Overview": "Control Codex Desktop tasks and actions from Ulanzi D200 Series in Ulanzi Studio.", - "Description": "Turn Ulanzi D200 Series into a dedicated Codex Desktop control surface through Ulanzi Studio.\n\nFEATURES\n- View five recent Codex tasks with live status and open the exact selected task.\n- Use Fast, Usage, Pin, New, Fork, Steer, Mic, and Submit actions.\n- Switch the visible Codex task to Sol High, Luna Max, or Sol Medium.\n- Use the Latest Task & Scroll Encoder action to open task 1 and scroll Codex.\n- Install, repair, launch, and check Codex Bridge from every Action setup page.\n- Keep Bridge and CDP communication on authenticated loopback-only endpoints.\n\nINSTALLATION ENVIRONMENT\n- Windows 10 or later, or macOS 13 or later\n- Codex Desktop Stable or Beta\n- Ulanzi Studio 3.0.1 or later\n- Ulanzi D200 Series connected in Ulanzi Studio\n- Node.js 20 or later only for manual repository setup\n\nMANUAL INSTALLATION\n1. Clone the repository and open plugins/codex-console.\n2. Quit Ulanzi Studio.\n3. Run: npm install\n4. Run: npm run install:plugin\n5. Run: npm run setup\n6. Reopen Ulanzi Studio and select any Action to check Bridge, service, and CDP status.\n\nIMPORTANT\nOn Windows, Codex Bridge dynamically locates the installed Stable or Beta Appx package and starts Codex with loopback-only CDP arguments. It does not use a version-pinned WindowsApps path.", + "Description": "Turn Ulanzi D200 Series into a dedicated Codex Desktop control surface through Ulanzi Studio.\n\nFEATURES\n- View five recent Codex tasks with live status and open the exact selected task.\n- Use Fast, Usage, Pin, New, Fork, Steer, Mic, and Submit actions.\n- Switch the visible Codex task to Sol High, Luna Max, or Sol Medium.\n- Use the Latest Task & Scroll Encoder action to open task 1 and scroll Codex.\n- Install, repair, launch, and check Codex Bridge from every Action setup page.\n- Keep Bridge and CDP communication on authenticated loopback-only endpoints.\n\nINSTALLATION ENVIRONMENT\n- Windows 10 or later, or macOS 13 or later\n- Codex Desktop Stable or Beta\n- Ulanzi Studio 3.0.1 or later\n- Ulanzi D200 Series connected in Ulanzi Studio\n- Node.js 20 or later only for manual repository setup\n\nMANUAL INSTALLATION\n1. Clone the repository and open the OpenCodexMicro directory.\n2. Quit Ulanzi Studio.\n3. Run: npm install\n4. Run: npm run install:plugin\n5. Run: npm run setup\n6. Reopen Ulanzi Studio and select any Action to check Bridge, service, and CDP status.\n\nIMPORTANT\nOn Windows, Codex Bridge dynamically locates the installed Stable or Beta Appx package and starts Codex with loopback-only CDP arguments. It does not use a version-pinned WindowsApps path.", "Icon": "assets/icons/plugin.png", "Category": "AI", "CategoryIcon": "assets/icons/plugin.png", diff --git a/package-lock.json b/package-lock.json index b82b9c7..1d3962b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "open-codex-micro", "version": "0.6.1", "license": "MIT", + "dependencies": { + "koffi": "3.1.5" + }, "devDependencies": { "esbuild": "^0.25.12", "ws": "^8.21.1" @@ -458,6 +461,246 @@ "node": ">=18" } }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.5.tgz", + "integrity": "sha512-IpqITl2fJi3QN9bTtNnygWPdK7ScSjw3xtGu8e6feYGvimCysu+spgI5KyeslY2jTnqxGS9xr8pLAbLhGJ8edA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.5.tgz", + "integrity": "sha512-4Tia4BS5EV/+vN9eIrdToanVe+U/2VqTZCBgOzoUbPKjgky51eqM+3J4qdRUvmYJohcJNPob5/hsxeItUZrl1g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.5.tgz", + "integrity": "sha512-bP94uzseFO79NG3flpU3WxfyvltD+jzC/kN8FDZLi8J0VUZNW1Wmu8yq87KEzjX1qT9KyX4Y+elVbGqHXHTv2Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.5.tgz", + "integrity": "sha512-raFXXAPHzvCQWhaoMUF+Cc2ZWgg2UBU0RVoowHZhaw9nQYPC1pERcPRH+JA+SNIN6g4d2GFW6uPFc+QbUhsagA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.5.tgz", + "integrity": "sha512-h6RyBZmPMBIDWTABkJIhzDdYwSnYAJvTacHpEjbT55Arkmw1H15Rl7CFtXuEuBrqh+uivoCrRgA6vszl9CsJ9g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.5.tgz", + "integrity": "sha512-u0vCmKPu4yQDhl/ri1J6U3vDnvYtYjoZaIWb+oMbRXhVZeiqdE53MGPb+q2A7Dj2n9IbYloAfEICQbL6l0pmiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.5.tgz", + "integrity": "sha512-Xa5JbumWglwPVZgrJcLhqyC1wCWlfm7+C00p3FuOTNGp0qoYuf2/tOoAh0/q7+taVm30cMopu/6lRHwdmF6I/w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.5.tgz", + "integrity": "sha512-F3i2CeTcqVBUQiSRUBSEzX1VgtXmLLiZb/ouZtXHkWpTLhJNd9TH7s3CizTofci0VRqlKdexGUYhK5vzPDAAHA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.5.tgz", + "integrity": "sha512-2TgQuzy+4PfDg+rw3kOmN6lywEWdzKT3eaLPbOp0b/9DaN7CLBJ/QIR5GhGsMNpeI30zU0YzFeYFxoVoJ/LqFw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.5.tgz", + "integrity": "sha512-2yaIg/1V0m4CiAUMzG4CIlWmq1WJ+QBMlFfaGr9su+OH5fuIqC7V3BbMiB03IwIl1VofIZO5JA4Db4lID8tpbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.5.tgz", + "integrity": "sha512-8/OXd+u9omMooykhvdJPEP7u6FFzzrrFo9gOmSHc9/DPt3XkVYOtSsE97PDk6zYaAzwIYHSKjHvIXsfFwFc7sg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.5.tgz", + "integrity": "sha512-SpeqldKkuDk2aTj5PVWumy7eq6Tr2GtBPAOI1NiDHhg8xe433KraxrA9V9UjEd+1+kSGJQGR04nQByN3MPA9PQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.5.tgz", + "integrity": "sha512-uej3YAEKAhlfVPoIo5sOwtxhTLRVJ01LgtWrKGpnnAQU3C+Ilmaxdh+Oc2xc1G3NK30N5eCVJpyO9r3pjKC6Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.5.tgz", + "integrity": "sha512-d42jv2f4PwtJGNJS19Xfn/BRtGsBNNVkw0O0K5tkIGI+yNq4MnPTSUsaGbDIkCLNsCgk/LFqAaE8BExyCdrujA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.5.tgz", + "integrity": "sha512-Pyo1WEHEP6Ek2NEn2pquwJzSPLOdY4vymoPSz0an1DgvFWSkOyBYYkGVoxL1ajfj5I1pPdXysYqixtVTzAtOfQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -500,6 +743,33 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/koffi": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.5.tgz", + "integrity": "sha512-XVwwrxg0Ca6IEUQF4YtGIU4XN0LSselFYpYvgfhh8wafCunhEEx5hPr7LZhp5QyeFA/LcRsKHTqncCdjWjWAlg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.5", + "@koromix/koffi-darwin-x64": "3.1.5", + "@koromix/koffi-freebsd-arm64": "3.1.5", + "@koromix/koffi-freebsd-ia32": "3.1.5", + "@koromix/koffi-freebsd-x64": "3.1.5", + "@koromix/koffi-linux-arm64": "3.1.5", + "@koromix/koffi-linux-ia32": "3.1.5", + "@koromix/koffi-linux-loong64": "3.1.5", + "@koromix/koffi-linux-riscv64": "3.1.5", + "@koromix/koffi-linux-x64": "3.1.5", + "@koromix/koffi-openbsd-ia32": "3.1.5", + "@koromix/koffi-openbsd-x64": "3.1.5", + "@koromix/koffi-win32-arm64": "3.1.5", + "@koromix/koffi-win32-ia32": "3.1.5", + "@koromix/koffi-win32-x64": "3.1.5" + } + }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", diff --git a/package.json b/package.json index 4ef07a0..d90a39e 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,12 @@ "node": ">=20" }, "scripts": { - "test": "node --test test/*_test.mjs", + "test": "npm run build:plugin && node --test test/*_test.mjs", "build:bridge": "node scripts/build-bridge.mjs", "build:plugin": "npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run build", "check:node": "node --check scripts/install.mjs && node --check scripts/install-plugin.mjs && node --check scripts/launch.mjs && node --check scripts/uninstall.mjs && node --check scripts/build-bridge.mjs && node --check src/bridge/thread-key.mjs && node --check src/bridge/auth.mjs && node --check src/bridge/windows-focus.mjs && node --check src/bridge/navigation.mjs && node --check src/bridge/platform.mjs && node --check src/bridge/codex-cdp.mjs && node --check src/bridge/server.mjs", "check:plugin": "npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run check && npm --prefix integration/com.ulanzi.codexmicro.ulanziPlugin run smoke", - "check": "npm run test && npm run check:node && npm run build:bridge && npm run build:plugin && npm run check:plugin", + "check": "npm run check:node && npm run build:bridge && npm run test && npm run check:plugin", "setup": "node scripts/install.mjs", "install:plugin": "node scripts/install-plugin.mjs", "bridge:start": "node scripts/launch.mjs",