diff --git a/README.md b/README.md index d8ba799..743045d 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 @@ -85,13 +87,15 @@ 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`. +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. 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 +106,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 +131,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..30dcbc5 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,12 @@ npm run setup ``` 运行 `npm run install:plugin` 前请退出 Ulanzi Studio。该命令会校验并原子安装仓库 -中的预构建插件;`npm run setup` 会构建本机 Bridge sidecar、注册用户级 -LaunchAgent,并把 `Codex Bridge.app` 安装到 `~/Applications`。 +中的预构建插件;若 Bridge 已安装,还会校验其版本与 runtime hash,并在需要时安全 +更新和重启。`npm run setup` 用于首次安装 Bridge 或显式修复。 -重新打开 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 +97,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 +118,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/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/docs/architecture.md b/docs/architecture.md index fe3e9f7..a5a7547 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. @@ -29,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 @@ -39,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 @@ -52,6 +68,8 @@ 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`. -- `scripts/install-plugin.mjs` installs only the Ulanzi Studio plugin directory. + its platform-specific user-level launcher/lifecycle files. +- `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 62ef190..9c2c621 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,11 +23,22 @@ 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 | - -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. +| 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 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. + +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 @@ -37,10 +48,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/errors.md b/docs/errors.md index 94d8dcc..c901087 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -32,8 +32,10 @@ - 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 - 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 f21d2b6..7e2263b 100644 --- a/docs/setup-and-operations.md +++ b/docs/setup-and-operations.md @@ -8,72 +8,75 @@ 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 +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. -From the repository root: +Windows installs Bridge data under: -```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 the `OpenCodexMicro` repository root: -```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. +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: ```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. - -## Starting Codex +Use `npm run setup -- --no-start` to install Bridge without launching Codex, or +`npm run bridge:start` to launch an existing installation. -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 +84,65 @@ 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 +`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: -```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/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 0000000..89c8bdb Binary files /dev/null and b/integration/com.ulanzi.codexmicro.ulanziPlugin/assets/icons/task-complete.gif differ 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..48603a2 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/build.mjs @@ -1,10 +1,13 @@ -import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +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 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 }); await build({ @@ -26,10 +29,46 @@ await build({ outfile: resolve(installerRoot, "bridge.mjs"), platform: "node", target: "node20", + external: ["koffi"], + define: { + __CODEX_BRIDGE_VERSION__: JSON.stringify(String(manifest.Version)) + }, banner: { 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(repositoryRoot, "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/de_DE.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/de_DE.json index fb0a678..c0a8000 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, drei GPT-5.6-Modellvoreinstellungen 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." }, @@ -16,12 +16,15 @@ { "Name": "Codex-Aufgabe abzweigen", "Tooltip": "Zweigt die aktive Codex-Aufgabe in eine neue Aufgabe ab." }, { "Name": "Codex steuern", "Tooltip": "Sendet den aktuellen Eingabetext als Steueranweisung an die laufende Aufgabe." }, { "Name": "Codex-Mikrofon", "Tooltip": "Schaltet die Codex-Mikrofonaktion um." }, - { "Name": "An Codex senden", "Tooltip": "Sendet den aktuellen Codex-Eingabetext oder stellt ihn in die Warteschlange." } + { "Name": "An Codex senden", "Tooltip": "Sendet den aktuellen Codex-Eingabetext oder stellt ihn in die Warteschlange." }, + { "Name": "Codex Sol Hoch", "Tooltip": "Wechselt die sichtbare Codex-Aufgabe zu GPT-5.6 Sol mit hoher Denkintensität." }, + { "Name": "Codex Luna Maximum", "Tooltip": "Wechselt die sichtbare Codex-Aufgabe zu GPT-5.6 Luna mit maximaler Denkintensität." }, + { "Name": "Codex Sol Mittel", "Tooltip": "Wechselt die sichtbare Codex-Aufgabe zu GPT-5.6 Sol mit mittlerer Denkintensität." } ], "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 +33,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/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/en.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/en.json index 4df2379..1ce0660 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, switch among three GPT-5.6 model presets, 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." }, @@ -16,12 +16,15 @@ { "Name": "Fork Codex Task", "Tooltip": "Fork the active Codex task into a new task." }, { "Name": "Steer Codex", "Tooltip": "Send the current composer text as steering input to the running task." }, { "Name": "Codex Microphone", "Tooltip": "Toggle the Codex microphone action." }, - { "Name": "Submit to Codex", "Tooltip": "Submit or queue the current Codex composer text." } + { "Name": "Submit to Codex", "Tooltip": "Submit or queue the current Codex composer text." }, + { "Name": "Codex Sol High", "Tooltip": "Switch the visible Codex task to GPT-5.6 Sol with high reasoning effort." }, + { "Name": "Codex Luna Max", "Tooltip": "Switch the visible Codex task to GPT-5.6 Luna with maximum reasoning effort." }, + { "Name": "Codex Sol Medium", "Tooltip": "Switch the visible Codex task to GPT-5.6 Sol with medium reasoning effort." } ], "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 +33,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..0d55750 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, tres preajustes de modelo GPT-5.6 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." }, @@ -16,12 +16,15 @@ { "Name": "Bifurcar tarea de Codex", "Tooltip": "Bifurca la tarea activa de Codex en una tarea nueva." }, { "Name": "Orientar Codex", "Tooltip": "Envía el texto actual del editor como orientación a la tarea en ejecución." }, { "Name": "Micrófono de Codex", "Tooltip": "Activa o desactiva la acción de micrófono de Codex." }, - { "Name": "Enviar a Codex", "Tooltip": "Envía o pone en cola el texto actual del editor de Codex." } + { "Name": "Enviar a Codex", "Tooltip": "Envía o pone en cola el texto actual del editor de Codex." }, + { "Name": "Codex Sol Alto", "Tooltip": "Cambia la tarea visible de Codex a GPT-5.6 Sol con razonamiento alto." }, + { "Name": "Codex Luna Máximo", "Tooltip": "Cambia la tarea visible de Codex a GPT-5.6 Luna con razonamiento máximo." }, + { "Name": "Codex Sol Medio", "Tooltip": "Cambia la tarea visible de Codex a GPT-5.6 Sol con razonamiento medio." } ], "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 +33,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/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/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/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/ja_JP.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/ja_JP.json index a3c82cd..28227bd 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、3 つの GPT-5.6 モデルプリセット、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 を開き、リアルタイム状態を表示します。" }, @@ -16,12 +16,15 @@ { "Name": "Codex タスクをフォーク", "Tooltip": "アクティブな Codex タスクを新しいタスクとしてフォークします。" }, { "Name": "Codex を誘導", "Tooltip": "現在の入力欄のテキストを実行中タスクへの誘導入力として送信します。" }, { "Name": "Codex マイク", "Tooltip": "Codex のマイク操作を切り替えます。" }, - { "Name": "Codex に送信", "Tooltip": "現在の Codex 入力欄のテキストを送信またはキューに追加します。" } + { "Name": "Codex に送信", "Tooltip": "現在の Codex 入力欄のテキストを送信またはキューに追加します。" }, + { "Name": "Codex Sol High", "Tooltip": "表示中の Codex タスクを GPT-5.6 Sol、高推論強度に切り替えます。" }, + { "Name": "Codex Luna Max", "Tooltip": "表示中の Codex タスクを GPT-5.6 Luna、最大推論強度に切り替えます。" }, + { "Name": "Codex Sol Medium", "Tooltip": "表示中の Codex タスクを GPT-5.6 Sol、中推論強度に切り替えます。" } ], "Localization": { "BridgeSetup": "Codex Bridge 設定", "BridgeSetupDescription": "ローカルの Codex 接続をインストール、起動、確認します。", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge サービス", "CDPConnection": "CDP 接続", "InstallRepair": "インストール / 修復", @@ -30,14 +33,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..dfbc904 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, GPT-5.6 모델 프리셋 3개 및 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를 열고 실시간 상태를 표시합니다." }, @@ -16,12 +16,15 @@ { "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 설정", "BridgeSetupDescription": "로컬 Codex 연결을 설치하고 실행한 뒤 확인합니다.", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge 서비스", "CDPConnection": "CDP 연결", "InstallRepair": "설치 / 복구", @@ -30,14 +33,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..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- 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- 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", @@ -11,7 +11,7 @@ "assets/banners/codex-micro-banner-2.png", "assets/banners/codex-micro-banner-3.png" ], - "Version": "0.4.0", + "Version": "0.6.1", "UUID": "com.ulanzi.ulanzistudio.codexmicro", "Type": "JavaScript", "CodePath": "dist/app.js", @@ -22,58 +22,62 @@ { "Platform": "mac", "MinimumVersion": "13.0" + }, + { + "Platform": "windows", + "MinimumVersion": "10.0" } ], "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", @@ -117,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", @@ -165,6 +169,36 @@ "Controllers": ["Keypad"], "DisableAutomaticStates": true, "States": [{ "Name": "Submit", "Image": "assets/icons/submit.png" }] + }, + { + "Name": "Codex Sol High", + "Icon": "assets/icons/model-sol-high.svg", + "PropertyInspectorPath": "property-inspector/setup.html", + "UUID": "com.ulanzi.ulanzistudio.codexmicro.model-sol-high", + "Tooltip": "Switch the visible Codex task to GPT-5.6 Sol with high reasoning effort.", + "Controllers": ["Keypad"], + "DisableAutomaticStates": true, + "States": [{ "Name": "Sol High", "Image": "assets/icons/model-sol-high.svg" }] + }, + { + "Name": "Codex Luna Max", + "Icon": "assets/icons/model-luna-max.svg", + "PropertyInspectorPath": "property-inspector/setup.html", + "UUID": "com.ulanzi.ulanzistudio.codexmicro.model-luna-max", + "Tooltip": "Switch the visible Codex task to GPT-5.6 Luna with maximum reasoning effort.", + "Controllers": ["Keypad"], + "DisableAutomaticStates": true, + "States": [{ "Name": "Luna Max", "Image": "assets/icons/model-luna-max.svg" }] + }, + { + "Name": "Codex Sol Medium", + "Icon": "assets/icons/model-sol-medium.svg", + "PropertyInspectorPath": "property-inspector/setup.html", + "UUID": "com.ulanzi.ulanzistudio.codexmicro.model-sol-medium", + "Tooltip": "Switch the visible Codex task to GPT-5.6 Sol with medium reasoning effort.", + "Controllers": ["Keypad"], + "DisableAutomaticStates": true, + "States": [{ "Name": "Sol Medium", "Image": "assets/icons/model-sol-medium.svg" }] } ] } diff --git a/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/package-lock.json index e0c483d..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.4.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-codex-micro-ulanzi-plugin", - "version": "0.4.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 c2b4613..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.4.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 7c93e76..27e4200 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"); @@ -27,15 +71,29 @@ const 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" }); -const 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" +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_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; let socket; let reconnectTimer; @@ -43,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}`); @@ -188,18 +249,185 @@ 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 = 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 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 shortTitle(value) { - const title = String(value || "Untitled").replace(/\s+/g, " ").trim(); - return title.length > 18 ? `${title.slice(0, 17)}…` : title; +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); + const maxTotalUnits = TASK_TITLE_MAX_UNITS * TASK_TITLE_MAX_LINES; + 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("…"); + } + + 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("…")) { + 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 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) { @@ -222,8 +450,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({ @@ -233,10 +465,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 }] } }); @@ -269,7 +502,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"); } @@ -282,34 +515,37 @@ 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() { 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(); @@ -319,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() { @@ -334,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); @@ -352,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); } } @@ -451,6 +756,11 @@ 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 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/plugin/bridge-installer.js b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js index 801203a..bd4b818 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js @@ -2,17 +2,25 @@ import { constants as fsConstants } from "node:fs"; import { access, chmod, + cp, copyFile, mkdir, readFile, realpath, + rename, rm, + stat, writeFile } from "node:fs/promises"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; import { promisify } from "node:util"; import { homedir } from "node:os"; -import { delimiter, join, resolve } from "node:path"; +import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; +import { + discoverDebugEndpoint, + launchWindowsCodex +} from "../../../src/bridge/platform.mjs"; const execFileAsync = promisify(execFile); @@ -42,6 +50,93 @@ async function readJson(path) { } } +async function fileSha256(path) { + try { + return createHash("sha256").update(await readFile(path)).digest("hex"); + } catch { + return null; + } +} + +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 { + 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"]); @@ -53,6 +148,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 +172,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 +223,32 @@ 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, + serviceStartTimeoutMs = 8000, + wait = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)) }) { - 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 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 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"); + const userApplications = join(home, "Applications"); const bridgeApp = join(userApplications, "Codex Bridge.app"); const bridgeContents = join(bridgeApp, "Contents"); @@ -129,72 +257,148 @@ 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; + 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 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, fsConstants.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(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 && 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 = 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 +406,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, appVersion = version) { 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 @@ -242,120 +425,511 @@ export 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 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 + 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 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)} + EnvironmentVariables + CODEX_BRIDGE_DATA_ROOT${xml(appRoot)} + + RunAtLoadKeepAlive + ProcessTypeBackgroundThrottleInterval2 StandardOutPath${xml(join(appRoot, "bridge.log"))} StandardErrorPath${xml(join(appRoot, "bridge-error.log"))} -`; - await writeFile(bridgeAgent, plist, { mode: 0o644 }); - await writeFile(installMetadata, `${JSON.stringify({ - version, - nodeExecutable: nodeRuntime.executable, - nodeVersion: nodeRuntime.version, - nodeSource: nodeRuntime.source, - installedAt: new Date().toISOString() - }, null, 2)}\n`, { mode: 0o600 }); +`, { mode: 0o644 }); + await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + } + + 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 execute("/bin/launchctl", ["bootout", `gui/${uid}`, bridgeAgent]); - } catch { - // The service may not be loaded yet. + await rename(stagedRuntime, bridgeRuntime); + } catch (error) { + if (!await exists(bridgeRuntime) && await exists(runtimeBackup)) { + await rename(runtimeBackup, bridgeRuntime); + } + throw error; } - await execute("/bin/launchctl", ["bootstrap", `gui/${uid}`, bridgeAgent]); + } + + 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, + 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 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 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); + 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, + nativeRuntimeHash, + 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, 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: 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(); + } + if ( + createdNativeRuntime && + nativeRuntime && + previousMetadata?.nativeRuntimeHash !== nativeRuntimeHash + ) { + await 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 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 < 2000) return { ...await status(), ...probe }; + const metadata = await readJson(installMetadata); + 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(); + 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: join(nativeRuntimesRoot, metadata.nativeRuntimeHash), + CODEX_BRIDGE_NATIVE_HASH: metadata.nativeRuntimeHash + } : {}) + } + }); + 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, + 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, fsConstants.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 { - // The service may already be stopped or absent. + if (platform === "darwin") { + await stopService(); + await rm(bridgeAgent, { force: true }); + await rm(bridgeApp, { recursive: true, force: true }); + } else { + try { await stopService(); } catch {} } - await rm(bridgeAgent, { force: true }); await rm(appRoot, { recursive: true, force: true }); - await 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 }; } 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..994937a 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, três predefinições de modelo GPT-5.6 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." }, @@ -16,12 +16,15 @@ { "Name": "Bifurcar tarefa Codex", "Tooltip": "Bifurca a tarefa ativa do Codex numa nova tarefa." }, { "Name": "Orientar Codex", "Tooltip": "Envia o texto atual do editor como orientação para a tarefa em execução." }, { "Name": "Microfone Codex", "Tooltip": "Alterna a ação de microfone do Codex." }, - { "Name": "Enviar para o Codex", "Tooltip": "Envia ou coloca em fila o texto atual do editor do Codex." } + { "Name": "Enviar para o Codex", "Tooltip": "Envia ou coloca em fila o texto atual do editor do Codex." }, + { "Name": "Codex Sol Alto", "Tooltip": "Muda a tarefa Codex visível para GPT-5.6 Sol com raciocínio alto." }, + { "Name": "Codex Luna Máximo", "Tooltip": "Muda a tarefa Codex visível para GPT-5.6 Luna com raciocínio máximo." }, + { "Name": "Codex Sol Médio", "Tooltip": "Muda a tarefa Codex visível para GPT-5.6 Sol com raciocínio médio." } ], "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 +33,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..41a5c77 100644 --- a/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs +++ b/integration/com.ulanzi.codexmicro.ulanziPlugin/test/smoke.mjs @@ -1,23 +1,59 @@ 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 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" ); assert.deepEqual(navigateAction?.Controllers, ["Encoder"]); assert.equal(navigateAction?.Encoder?.layout, "$UA1"); -assert.equal(manifest.Version, "0.4.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 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" ); +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/); @@ -30,21 +66,21 @@ 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`); } +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"], @@ -56,22 +92,17 @@ 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 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 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"] }; -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 +116,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 +126,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,27 +136,41 @@ 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.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/); } 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, + nativeRuntimeHash: nativeRuntimeManifest.runtimeHash, + 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: "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" } @@ -141,6 +179,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: "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 } + ] + : [ + { 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)); @@ -155,9 +222,17 @@ 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"] }); +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) => { @@ -184,16 +259,18 @@ 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.6.1"); + assert.equal(setupStatus?.payload?.status?.serviceVersion, "0.6.1"); + assert.equal(setupStatus?.payload?.status?.serviceRuntimeHash, bundledBridgeHash); - 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 taskColors = [ + ["#2589f5"], + ["#28b875", "#6ce9a6"], + ["#ed9f20"], + ["#e34d62"], + ["#98a2b3"] ]; - for (let index = 0; index < taskPaths.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}`, @@ -202,29 +279,83 @@ try { param: {} })); } - await new Promise(resolve => setTimeout(resolve, 700)); - for (let index = 0; index < taskPaths.length; index += 1) { + 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]; - assert.equal(item?.type, 2); - assert.equal(item?.path, taskPaths[index]); + taskItems.push(item); + assert.equal(item?.type, 1); + assert.match(item?.data || "", /^data:image\/svg\+xml;base64,/); 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.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, /data-role="title-surface"/); + assert.doesNotMatch(svg, / - message.cmd === "state" && message.param?.statelist?.[0]?.uuid.endsWith(".task1") + 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(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.equal(task1State?.param?.statelist?.[0]?.textdata, "Working task"); + 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", @@ -251,10 +382,19 @@ 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, 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.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=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/); + 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/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json b/integration/com.ulanzi.codexmicro.ulanziPlugin/zh_CN.json index e055a1f..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 专用控制台。\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、三个 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,12 +16,15 @@ { "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 设置", "BridgeSetupDescription": "安装、启动并检查本机 Codex 连接。", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge 后台服务", "CDPConnection": "CDP 连接", "InstallRepair": "安装 / 修复", @@ -30,14 +33,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..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 專用控制台。\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、三個 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,12 +16,15 @@ { "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 設定", "BridgeSetupDescription": "安裝、啟動並檢查本機 Codex 連線。", - "BridgeApp": "Codex Bridge.app", + "BridgeApp": "Codex Bridge", "BridgeService": "Bridge 背景服務", "CDPConnection": "CDP 連線", "InstallRepair": "安裝 / 修復", @@ -30,14 +33,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..1d3962b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "open-codex-micro", - "version": "0.4.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-codex-micro", - "version": "0.4.0", + "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 baad80d..d90a39e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-codex-micro", - "version": "0.4.0", + "version": "0.6.1", "private": true, "type": "module", "description": "Control Codex Desktop from Ulanzi Studio through a local bridge", @@ -9,17 +9,20 @@ "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/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/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": "open \"$HOME/Applications/Codex Bridge.app\"", + "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/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 329b881..c6b3274 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" @@ -23,14 +27,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,12 +58,19 @@ 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", "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}`); @@ -80,6 +86,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 }); @@ -88,6 +98,7 @@ for (const relative of [ "manifest.json", ...localizationFiles, "assets/icons", + "assets/banners", "dist", "installer", "property-inspector" @@ -100,16 +111,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/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..039b5c4 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 { discoverDebugEndpoint, 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 @@ -20,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", @@ -90,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"]')] @@ -347,48 +356,125 @@ 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: 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 {} +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"); } export class CodexCdpClient { socket = null; + connectPromise = null; + connectionGeneration = 0; nextId = 0; pending = new Map(); lastSnapshot = null; + connectionIdentity = null; + modelActionQueue = Promise.resolve(); + modelPickerLayout = null; + + constructor({ + discoverPort = discoverDebugEndpoint, + 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 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 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); + 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(() => { + 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() { @@ -410,23 +496,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 } + ); } } @@ -444,23 +565,330 @@ 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) { + 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()); @@ -539,7 +967,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")); } @@ -549,13 +977,80 @@ 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, 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) { @@ -573,13 +1068,21 @@ export class CodexCdpClient { ?? "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 === 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/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 new file mode 100644 index 0000000..27cfe8f --- /dev/null +++ b/src/bridge/platform.mjs @@ -0,0 +1,244 @@ +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' +$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 +`; + +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_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 }; +} + +export function debugPortsFromCommandLines(text) { + 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 {} + } + 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) { + 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: 4000 }) + )).stdout; + } + if (platform === "darwin") { + return (await execute("/bin/ps", ["-axo", "command="], { timeout: 4000 })).stdout; + } + return ""; +} + +export async function discoverDebugEndpoint({ + platform = process.platform, + execute = execFileAsync, + fetchImpl = fetch, + preferredPort = DEFAULT_CDP_PORT +} = {}) { + let processes = []; + try { + processes = debugProcessesFromCommandLines(await processCommandLines(platform, execute)); + } catch { + // Direct loopback probing remains available when process inspection is denied. + } + 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 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", + powershellArgs(WINDOWS_PACKAGE_COMMAND), + powershellOptions({ 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, + processId = null, + activateWindows = null +} = {}) { + if (platform === "win32") { + 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 }); + 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), + powershellOptions({ + 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..309c166 100644 --- a/src/bridge/server.mjs +++ b/src/bridge/server.mjs @@ -1,17 +1,41 @@ import { createServer } from "node:http"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +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"; +import { bridgeRequestAuthorized } from "./auth.mjs"; +import { focusCodex } from "./platform.mjs"; +import { navigateAndFocus } from "./navigation.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 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 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) : 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) => ({ @@ -22,11 +46,132 @@ 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; -async function focusCodex() { - await execFileAsync("/usr/bin/open", ["-b", "com.openai.codex"], { - timeout: 3000 - }); +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) { @@ -45,6 +190,7 @@ async function refresh(force = false) { try { await refreshPromise; } finally { + hasRefreshed = true; refreshPromise = null; } } @@ -60,16 +206,35 @@ 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 }); + 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 }); @@ -78,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])), - 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 }); } @@ -91,41 +256,64 @@ 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"); } - 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( @@ -143,7 +331,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/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 e663abb..4880a9b 100644 --- a/test/bridge-installer_test.mjs +++ b/test/bridge-installer_test.mjs @@ -1,11 +1,16 @@ 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, 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"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { + acquireFilesystemLock, + bridgeDataRoot, createBridgeInstaller, selectBridgeNodeRuntime } from "../integration/com.ulanzi.codexmicro.ulanziPlugin/plugin/bridge-installer.js"; @@ -14,6 +19,73 @@ 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); +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"); +} + +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-")); @@ -23,9 +95,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)); @@ -33,7 +119,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: "" }; }; @@ -72,6 +164,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")); @@ -111,3 +210,457 @@ 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"); + 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; + 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: "" }; + 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: "" + }; + } + 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 }); + if (args[0] === bridgeRuntime) { + serviceOnline = true; + runningVersion = "9.8.7"; + runningRuntimeHash = bundledRuntimeHash; + runningNativeRuntimeHash = bundledNativeRuntimeHash; + } + return { pid: 1000 + children.length, 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 url => { + if (String(url).endsWith("/health") && serviceOnline) { + return { + ok: true, + async json() { + return { + ok: true, + bridgeVersion: runningVersion, + runtimeHash: runningRuntimeHash, + nativeRuntimeHash: runningNativeRuntimeHash, + codexConnected: false + }; + } + }; + } + throw new Error("offline fixture"); + }, + serviceStartTimeoutMs: 500 + }); + + try { + assert.deepEqual(await installer.authorizationHeaders(), {}); + 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.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); + 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"; + 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[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[2].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)); + 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 }); + } +}); + +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 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); + 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"; + runningNativeRuntimeHash = bundledNativeRuntimeHash; + } else { + runningVersion = "1.0.0"; + runningRuntimeHash = oldRuntimeHash; + runningNativeRuntimeHash = null; + } + } + 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, + nativeRuntimeHash: runningNativeRuntimeHash, + 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 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); + 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; + runningNativeRuntimeHash = bundledNativeRuntimeHash; + 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, + nativeRuntimeHash: runningNativeRuntimeHash, + 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 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, nativeRuntimeHash = null) => ({ + version, + runtimeHash, + nativeRuntimeHash, + 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 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, 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"); + + 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"; + 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() {} }; + }, + fetchImpl: async url => { + if (String(url).endsWith("/health") && serviceOnline) { + return { + ok: true, + async json() { + return { + ok: true, + bridgeVersion: runningVersion, + runtimeHash: runningRuntimeHash, + nativeRuntimeHash: runningNativeRuntimeHash, + 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 78c8e72..7bb8a45 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"; @@ -10,11 +11,307 @@ import { import { CodexCdpClient, composerSteerExpression, + modelPreset, rendererActionExpression } 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"; +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); + assert.equal(bridgeRequestAuthorized(token, ""), false); + assert.equal(bridgeRequestAuthorized(token, "Bearer wrong-token"), false); + assert.equal(bridgeRequestAuthorized(token, `Bearer ${token}`), true); +}); + +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({ + 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, 1); +}); + +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 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) => { + 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 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 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]) + }; + 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", + processId: 4202, + execute: async () => { powershellCalls += 1; }, + activateWindows: async processId => ({ processId, alreadyForeground: true, alreadyMaximized: true }) + }); + assert.equal(focused.processId, 4202); + assert.equal(powershellCalls, 0, "the key hot path must not spawn PowerShell"); +}); + +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 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]); +}); + +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/ + ); +}); + +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; + + 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", () => { assert.equal(validateThreadId(UUID), UUID); assert.equal( @@ -53,16 +350,130 @@ 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("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", () => { 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; + } +});