From 9fbe3ea88e30ee5788f0a84df4aa3cd722f440ab Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Fri, 16 Oct 2026 23:21:16 +0900
Subject: [PATCH 01/98] feat: add default-on live-voice override for the
karlorz fork
Ship the Desktop Statsig wrap and Settings switch on by default as v0.13.2-1,
warn when CODEXHOST_CPA_LIVE_VOICE=0 needs a relaunch, and retarget npm/GitHub
identity to @karlorz/codexhost-cli.
---
.agents/skills/codexhost-release/SKILL.md | 3 +
.../references/release-guide.md | 6 +-
Cargo.lock | 10 +-
Cargo.toml | 2 +-
README.md | 14 +-
crates/platform/src/desktop_launch.rs | 3 +-
crates/updater/src/install.rs | 2 +-
crates/updater/src/remote.rs | 14 +-
crates/updater/src/ssh.rs | 26 +-
crates/updater/src/ssh/tests.rs | 10 +-
docs/PRIVACY.md | 2 +-
.../harness-executable-discovery.md | 2 +-
docs/index.md | 2 +
docs/operations/codexhost-console.md | 4 +-
.../operations/cpa-live-voice-desktop-hook.md | 46 ++++
docs/operations/fork-versioning.md | 89 +++++++
docs/platforms/linux/linux.md | 2 +-
docs/platforms/linux/linux.zh-CN.md | 2 +-
docs/platforms/remote/remote-ssh-host.md | 4 +-
.../platforms/remote/remote-ssh-host.zh-CN.md | 4 +-
docs/project/README.ko.md | 14 +-
docs/project/README.zh-CN.md | 14 +-
package-lock.json | 4 +-
package.json | 2 +-
.../console-server/src/diagnostic-report.ts | 2 +-
packages/console-server/test/server.test.ts | 2 +-
packages/console-server/test/updates.test.ts | 2 +-
.../src/cpa-live-voice-hook.ts | 17 ++
packages/desktop-control/src/index.ts | 1 +
.../src/production-controller.ts | 18 +-
.../src/renderer-cdp-control-session.ts | 156 +++++++++---
.../test/cpa-live-voice-hook.test.ts | 32 +++
.../test/production-controller.test.ts | 100 +++++++-
.../test/renderer-cdp-control-session.test.ts | 230 ++++++++++++++++++
.../test/app-server-host.projection-1.test.ts | 2 +-
.../test/runtime-maintenance.test.ts | 2 +-
.../test/update-coordinator.test.ts | 6 +-
packages/renderer-extension/scripts/build.mjs | 1 +
.../src/cpa-live-voice-entry.ts | 4 +
.../src/production-entry.ts | 9 +-
.../src/renderer-cpa-live-voice-hook.ts | 198 +++++++++++++++
.../src/renderer-cpa-live-voice-preference.ts | 30 +++
.../src/settings/appearance-page.ts | 33 +++
.../src/settings/connections-page.ts | 3 +-
.../src/settings/localization.ts | 27 ++
.../renderer-extension/src/settings/pages.ts | 6 +-
.../test/renderer-cpa-live-voice-hook.test.ts | 198 +++++++++++++++
...renderer-cpa-live-voice-preference.test.ts | 59 +++++
.../test/renderer-model-client.test.ts | 4 +-
.../test/settings/localization.test.ts | 13 +
.../test/settings/pages.test.ts | 23 +-
.../test/settings/release-notes.test.ts | 12 +-
packages/shared-contracts/src/updates.ts | 2 +-
.../shared-contracts/test/updates.test.ts | 4 +-
.../update-manager/src/github-cli-release.ts | 8 +-
packages/update-manager/src/github-release.ts | 12 +-
.../test/github-release.test.ts | 12 +-
scripts/release/macos/package.sh | 1 +
scripts/release/prepare-npm-meta.mjs | 8 +-
scripts/release/prepare-npm.mjs | 59 +++--
scripts/release/prepare-payload.mjs | 7 +
scripts/release/publish-npm.mjs | 8 +-
tests/release/npm-package.test.mjs | 61 +++--
tests/release/npm-publish.test.mjs | 14 +-
tests/release/payload.test.mjs | 4 +-
tests/release/production-renderer.test.mjs | 3 +
66 files changed, 1455 insertions(+), 219 deletions(-)
create mode 100644 docs/operations/cpa-live-voice-desktop-hook.md
create mode 100644 docs/operations/fork-versioning.md
create mode 100644 packages/desktop-control/src/cpa-live-voice-hook.ts
create mode 100644 packages/desktop-control/test/cpa-live-voice-hook.test.ts
create mode 100644 packages/renderer-extension/src/cpa-live-voice-entry.ts
create mode 100644 packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts
create mode 100644 packages/renderer-extension/src/renderer-cpa-live-voice-preference.ts
create mode 100644 packages/renderer-extension/test/renderer-cpa-live-voice-hook.test.ts
create mode 100644 packages/renderer-extension/test/renderer-cpa-live-voice-preference.test.ts
diff --git a/.agents/skills/codexhost-release/SKILL.md b/.agents/skills/codexhost-release/SKILL.md
index 53cc20e17..232fdb6ad 100644
--- a/.agents/skills/codexhost-release/SKILL.md
+++ b/.agents/skills/codexhost-release/SKILL.md
@@ -17,6 +17,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
- `.github/workflows/release-packages.yml` 与 `.github/workflows/ci.yml`
- `packages/repository-automation/src/release.mjs`
- `docs/operations/repository-maintenance.md`
+- 本仓库若是 fork `karlorz/codex-host`:先读 [fork versioning](../../../docs/operations/fork-versioning.md)(`0.13.2-0` / `0.13.2-1`…,控制台版本同源),再读本 Skill 的上游渠道表
- 涉及更新提示时:`packages/update-manager/src/github-release.ts`
源码和工作流是执行行为的依据。发现指南与当前实现不一致时,说明差异;不要假装已有不支持的发布能力。
@@ -38,6 +39,8 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
用户意图不清楚时,先问清两个渠道的目标。最终执行前明确报告版本、npm dist-tag、GitHub 状态与是否提示更新。
+在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 仍为 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 在未改成 `@karlorz` Trusted Publisher 前保持 disabled。控制台「codexhost 版本」必须与该准备版本一致。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
+
## 2. 检查现场与验证
1. 检查工作区、当前分支、远端与已有标签;快进同步 `main`,不覆盖无关修改。
diff --git a/.agents/skills/codexhost-release/references/release-guide.md b/.agents/skills/codexhost-release/references/release-guide.md
index b7f3533da..9ff9fa7bd 100644
--- a/.agents/skills/codexhost-release/references/release-guide.md
+++ b/.agents/skills/codexhost-release/references/release-guide.md
@@ -20,7 +20,7 @@
| 稳定 SemVer,默认发布 | `latest` | 正式 Release、latest |
| 稳定 SemVer,手动发布且 `github_prerelease=true` | `latest` | Prerelease、非 latest |
-静默预览发行用于“小修复让用户自行下载,但不推送更新提示”:npm 正常发布到 `latest`,GitHub 标记为 Prerelease。当前更新检查读取 GitHub 正式 latest Release,不读取 npm dist-tag;预览发行仍公开可下载。SemVer 预发布则在 npm 使用 `next`,用户需显式安装 `@codexhost/cli@next`。
+静默预览发行用于“小修复让用户自行下载,但不推送更新提示”:npm 正常发布到 `latest`,GitHub 标记为 Prerelease。当前更新检查读取 GitHub 正式 latest Release,不读取 npm dist-tag;预览发行仍公开可下载。SemVer 预发布则在 npm 使用 `next`,用户需显式安装 `@karlorz/codexhost-cli@next`。
## 发布步骤
@@ -104,7 +104,7 @@ Composer 增加 Harness 命令按钮和模型搜索。Grok 支持自动或手动
### npm
```bash
-npm install -g @codexhost/cli
+npm install -g @karlorz/codexhost-cli
codexhost
```
@@ -178,7 +178,7 @@ Tag 推送后,GitHub Actions 会自动:
- 校验 Tag、npm 版本和 Cargo Workspace 版本一致。
- 构建 Windows x64/arm64 EXE。
- 构建 macOS x64/arm64 DMG。
-- 发布 macOS、Windows、Linux 的 x64/arm64 共六个平台 npm 包和 `@codexhost/cli` 元包。
+- 发布 macOS、Windows、Linux 的 x64/arm64 共六个平台 npm 包和 `@karlorz/codexhost-cli` 元包。
- 根据版本及手动发布输入创建公开 GitHub Release Prerelease 或正式 Release;`test` 渠道不创建 GitHub Release。
- GitHub Release 只上传四个安装包,不上传 npm tarball。
diff --git a/Cargo.lock b/Cargo.lock
index 236113572..134609827 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "codexhost-gate-a-native"
-version = "0.13.2"
+version = "0.13.2-1"
dependencies = [
"codexhost-platform",
"codexhost-shim",
@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "codexhost-launcher"
-version = "0.13.2"
+version = "0.13.2-1"
dependencies = [
"codexhost-platform",
"fs2",
@@ -106,7 +106,7 @@ dependencies = [
[[package]]
name = "codexhost-platform"
-version = "0.13.2"
+version = "0.13.2-1"
dependencies = [
"libproc",
"nix",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "codexhost-shim"
-version = "0.13.2"
+version = "0.13.2-1"
dependencies = [
"codexhost-platform",
"fs2",
@@ -131,7 +131,7 @@ dependencies = [
[[package]]
name = "codexhost-updater"
-version = "0.13.2"
+version = "0.13.2-1"
dependencies = [
"codexhost-platform",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index c355d0895..985314740 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ default-members = [
resolver = "3"
[workspace.package]
-version = "0.13.2"
+version = "0.13.2-1"
edition = "2024"
rust-version = "1.97.1"
publish = false
diff --git a/README.md b/README.md
index 250d0ed3b..f80ad77d9 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ But **Codex** isn't the only great **Agent Harness** — **Claude Code** and **P
-Download · Cross-Agent Collaboration · Remote · 微信交流群 · 简体中文 · 한국어
+Download · Cross-Agent Collaboration · Remote · 微信交流群 · 简体中文 · 한국어
@@ -67,13 +67,13 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76
**Option 1: npm** (macOS / Windows / Linux)
```bash
-npm install -g @codexhost/cli
+npm install -g @karlorz/codexhost-cli
codexhost
```
**Option 2: Installer** (macOS / Windows)
-Grab the installer for your platform from [Releases](https://github.com/BytePioneer-AI/codex-host/releases).
+Grab the installer for your platform from [Releases](https://github.com/karlorz/codex-host/releases).
> Linux is supported on x64 and ARM64. See the [Linux guide](docs/platforms/linux/linux.md).
@@ -225,7 +225,7 @@ Before you start, add the remote machine in Codex Desktop under **Settings → C
1. Install and start codexhost on the remote machine:
```bash
- npm install -g @codexhost/cli
+ npm install -g @karlorz/codexhost-cli
codexhost remote install
codexhost remote start
codexhost remote status
@@ -290,7 +290,7 @@ Please read the [contributing guide](CONTRIBUTING.md) before opening an issue or
Requirements: the official Codex Desktop, Node.js 22.19+ or 24, and Rust.
```bash
-git clone https://github.com/BytePioneer-AI/codex-host
+git clone https://github.com/karlorz/codex-host
cd codex-host
npm ci
npm start
@@ -322,8 +322,8 @@ Tip: point your coding Agent at the in-repo [codexhost-add-harness Skill](.agent
Thanks to everyone who has contributed to CodexHost.
-
-
+
+
## Star History
diff --git a/crates/platform/src/desktop_launch.rs b/crates/platform/src/desktop_launch.rs
index f204d136f..1ac2c1100 100644
--- a/crates/platform/src/desktop_launch.rs
+++ b/crates/platform/src/desktop_launch.rs
@@ -31,8 +31,7 @@ pub type DesktopProcess = super::windows_desktop::WindowsDesktopProcess;
#[cfg(not(target_os = "windows"))]
pub type DesktopProcess = Child;
-const CODEXHOST_RELEASES_LATEST_URL: &str =
- "https://github.com/BytePioneer-AI/codex-host/releases/latest";
+const CODEXHOST_RELEASES_LATEST_URL: &str = "https://github.com/karlorz/codex-host/releases/latest";
const REMOTE_SSH_MANAGED_ENV: &str = "CODEXHOST_REMOTE_SSH_MANAGED";
const REMOTE_PROFILE_ONLY_ENVIRONMENT: [&str; 3] = [
"CODEX_INSTALL_DIR",
diff --git a/crates/updater/src/install.rs b/crates/updater/src/install.rs
index baec17330..daca67a44 100644
--- a/crates/updater/src/install.rs
+++ b/crates/updater/src/install.rs
@@ -23,7 +23,7 @@ use crate::request::{
#[cfg(target_os = "macos")]
use crate::status::unix_seconds;
-const NPM_PACKAGE_NAME: &str = "@codexhost/cli";
+pub(crate) const NPM_PACKAGE_NAME: &str = "@karlorz/codexhost-cli";
#[cfg(any(target_os = "windows", target_os = "macos"))]
const DISTRIBUTION_FILE: &str = "codexhost-distribution.json";
diff --git a/crates/updater/src/remote.rs b/crates/updater/src/remote.rs
index 61f76f4f6..1e3cc1bd0 100644
--- a/crates/updater/src/remote.rs
+++ b/crates/updater/src/remote.rs
@@ -201,7 +201,7 @@ fn apply_locked(request: &Request) -> Result<(), Box> {
"npm uses a different global installation; update this remote manually".into(),
);
}
- let package = format!("@codexhost/cli@{}", request.version);
+ let package = format!("{}@{}", crate::install::NPM_PACKAGE_NAME, request.version);
// Unpublished local builds fail here, before touching the installation or service.
run(Command::new(&request.node).arg(&request.npm).args([
"view",
@@ -231,16 +231,16 @@ fn apply_locked(request: &Request) -> Result<(), Box> {
.and_then(|name| name.to_str())
.ok_or("Invalid platform package path")?;
if ![
- "cli-darwin-arm64",
- "cli-darwin-x64",
- "cli-linux-arm64",
- "cli-linux-x64",
+ "codexhost-cli-darwin-arm64",
+ "codexhost-cli-darwin-x64",
+ "codexhost-cli-linux-arm64",
+ "codexhost-cli-linux-x64",
]
.contains(&name)
{
return Err("Unknown remote platform package".into());
}
- command.arg(format!("@codexhost/{name}@{}", request.version));
+ command.arg(format!("@karlorz/{name}@{}", request.version));
}
run(&mut command).map_err(|error| format!("npm installation failed; check remote network access and write permissions: {error}"))?;
}
@@ -285,7 +285,7 @@ mod tests {
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
- let package_root = root.join("node_modules/@codexhost/cli-darwin-arm64");
+ let package_root = root.join("node_modules/@karlorz/codexhost-cli-darwin-arm64");
fs::create_dir_all(package_root.join("app")).unwrap();
fs::write(
package_root.join("app/codexhost-distribution.json"),
diff --git a/crates/updater/src/ssh.rs b/crates/updater/src/ssh.rs
index b8e3b4acb..675e0679c 100644
--- a/crates/updater/src/ssh.rs
+++ b/crates/updater/src/ssh.rs
@@ -8,6 +8,8 @@ use std::{
time::{Duration, Instant},
};
+use crate::install::NPM_PACKAGE_NAME;
+
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Request {
@@ -66,7 +68,8 @@ node -e 'const [major, minor] = process.versions.node.split(".").map(Number); pr
"install" => {
let version = release_version(request)?;
Ok(format!(
- "{node}set -e; {lock}[ ! -f \"$HOME/.codexhost/remote/manifest.json\" ] || exit 43; case $(uname -s) in Darwin|Linux) ;; *) exit 44;; esac; command -v npm >/dev/null 2>&1 || exit 56; command -v codex >/dev/null 2>&1 || exit 57; npm install -g @codexhost/cli@{version} >/dev/null 2>&1 || exit 46; codexhost remote install >/dev/null 2>&1 || exit 47; codexhost remote start >/dev/null 2>&1 || {started} || exit 48; printf '\\n__CODEXHOST_SSH_STATE__=installed\\n'"
+ "{node}set -e; {lock}[ ! -f \"$HOME/.codexhost/remote/manifest.json\" ] || exit 43; case $(uname -s) in Darwin|Linux) ;; *) exit 44;; esac; command -v npm >/dev/null 2>&1 || exit 56; command -v codex >/dev/null 2>&1 || exit 57; npm install -g {package}@{version} >/dev/null 2>&1 || exit 46; codexhost remote install >/dev/null 2>&1 || exit 47; codexhost remote start >/dev/null 2>&1 || {started} || exit 48; printf '\\n__CODEXHOST_SSH_STATE__=installed\\n'",
+ package = NPM_PACKAGE_NAME,
))
}
// For services too old to update themselves. A newer CLI refuses to stop an installation
@@ -75,7 +78,8 @@ node -e 'const [major, minor] = process.versions.node.split(".").map(Number); pr
"update" => {
let version = release_version(request)?;
Ok(format!(
- "{node}set -e; {lock}[ -f \"$HOME/.codexhost/remote/manifest.json\" ] || exit 52; command -v npm >/dev/null 2>&1 || exit 56; npm view @codexhost/cli@{version} version --fetch-retries=0 --fetch-timeout=30000 >/dev/null 2>&1 || exit 53; if command -v codexhost >/dev/null; then codexhost remote stop >/dev/null 2>&1 || true; fi; npm install -g @codexhost/cli@{version} >/dev/null 2>&1 || {{ codexhost remote start >/dev/null 2>&1 || true; exit 46; }}; command -v codexhost >/dev/null || exit 54; codexhost remote install >/dev/null 2>&1 || exit 47; codexhost remote stop >/dev/null 2>&1 || {stock} || exit 50; codexhost remote start >/dev/null 2>&1 || {started} || exit 48; printf '\\n__CODEXHOST_SSH_STATE__=installed\\n'"
+ "{node}set -e; {lock}[ -f \"$HOME/.codexhost/remote/manifest.json\" ] || exit 52; command -v npm >/dev/null 2>&1 || exit 56; npm view {package}@{version} version --fetch-retries=0 --fetch-timeout=30000 >/dev/null 2>&1 || exit 53; if command -v codexhost >/dev/null; then codexhost remote stop >/dev/null 2>&1 || true; fi; npm install -g {package}@{version} >/dev/null 2>&1 || {{ codexhost remote start >/dev/null 2>&1 || true; exit 46; }}; command -v codexhost >/dev/null || exit 54; codexhost remote install >/dev/null 2>&1 || exit 47; codexhost remote stop >/dev/null 2>&1 || {stock} || exit 50; codexhost remote start >/dev/null 2>&1 || {started} || exit 48; printf '\\n__CODEXHOST_SSH_STATE__=installed\\n'",
+ package = NPM_PACKAGE_NAME,
))
}
// A damaged installation is exactly what the CLI refuses to stop, so the first stop is
@@ -85,18 +89,24 @@ node -e 'const [major, minor] = process.versions.node.split(".").map(Number); pr
)),
"uninstall" => {
let package_check = if request.uninstall_package {
- r#"command -v npm >/dev/null 2>&1 || exit 56;
+ format!(
+ r#"command -v npm >/dev/null 2>&1 || exit 56;
NPM_ROOT=$(npm root --global 2>/dev/null) || exit 59;
CLI=$(command -v codexhost) || exit 58;
-node -e 'const fs = require("node:fs"), path = require("node:path"); try { const expected = path.join(process.argv[1], "@codexhost/cli/bin/codexhost.js"); process.exit(fs.realpathSync(expected) === fs.realpathSync(process.argv[2]) ? 0 : 1); } catch { process.exit(1); }' "$NPM_ROOT" "$CLI" >/dev/null 2>&1 || exit 59;
-"#
+node -e 'const fs = require("node:fs"), path = require("node:path"); try {{ const expected = path.join(process.argv[1], "{package}/bin/codexhost.js"); process.exit(fs.realpathSync(expected) === fs.realpathSync(process.argv[2]) ? 0 : 1); }} catch {{ process.exit(1); }}' "$NPM_ROOT" "$CLI" >/dev/null 2>&1 || exit 59;
+"#,
+ package = NPM_PACKAGE_NAME,
+ )
} else {
- ""
+ String::new()
};
let package_remove = if request.uninstall_package {
- "npm uninstall --global @codexhost/cli >/dev/null 2>&1 || exit 60; [ ! -e \"$NPM_ROOT/@codexhost/cli/package.json\" ] || exit 60;"
+ format!(
+ "npm uninstall --global {package} >/dev/null 2>&1 || exit 60; [ ! -e \"$NPM_ROOT/{package}/package.json\" ] || exit 60;",
+ package = NPM_PACKAGE_NAME,
+ )
} else {
- ""
+ String::new()
};
// Keep the npm package until its CLI has stopped the service and removed the
// managed entrypoint/profile (and the macOS broker). No user data is deleted.
diff --git a/crates/updater/src/ssh/tests.rs b/crates/updater/src/ssh/tests.rs
index 118342dc8..d9e83d9a4 100644
--- a/crates/updater/src/ssh/tests.rs
+++ b/crates/updater/src/ssh/tests.rs
@@ -165,7 +165,7 @@ node(){ case "$2" in *realpathSync*) return "${MATCH_CODE:-0}";; *) return 0;; e
npm(){
case "$1" in
root) printf '%s\n' "$HOME/npm";;
- uninstall) printf '%s\n' 'npm uninstall' >> "$HOME/calls"; [ "${FAIL:-}" != npm ] || return 1; rm "$HOME/npm/@codexhost/cli/package.json";;
+ uninstall) printf '%s\n' 'npm uninstall' >> "$HOME/calls"; [ "${FAIL:-}" != npm ] || return 1; rm "$HOME/npm/@karlorz/codexhost-cli/package.json";;
*) return 99;;
esac
}
@@ -224,7 +224,7 @@ codexhost(){
] {
let home = root.join(name);
let remote = home.join(".codexhost/remote");
- let package = home.join("npm/@codexhost/cli");
+ let package = home.join("npm/@karlorz/codexhost-cli");
std::fs::create_dir_all(remote.join("data")).unwrap();
std::fs::create_dir_all(&package).unwrap();
std::fs::write(remote.join("manifest.json"), "{}").unwrap();
@@ -370,7 +370,7 @@ fn install_refuses_to_replace_existing_remote_services() {
r.version = Some("1.2.3".into());
let value = script(&r).unwrap();
assert!(value.contains("exit 43"));
- assert!(value.contains("@codexhost/cli@1.2.3"));
+ assert!(value.contains("@karlorz/codexhost-cli@1.2.3"));
assert!(!value.contains("remote stop"));
}
#[cfg(unix)]
@@ -399,7 +399,7 @@ fn executes_initial_setup_and_refuses_an_existing_installation() {
let calls = std::fs::read_to_string(root.join("calls")).unwrap();
assert_eq!(
calls,
- "install -g @codexhost/cli@1.2.3\nremote install\nremote start\n"
+ "install -g @karlorz/codexhost-cli@1.2.3\nremote install\nremote start\n"
);
std::fs::create_dir_all(root.join(".codexhost/remote")).unwrap();
std::fs::write(root.join(".codexhost/remote/manifest.json"), "{}").unwrap();
@@ -421,7 +421,7 @@ fn executes_initial_setup_and_refuses_an_existing_installation() {
.unwrap();
assert!(updated.status.success());
let calls = format!(
- "{calls}view @codexhost/cli@1.2.4 version --fetch-retries=0 --fetch-timeout=30000\nremote stop\ninstall -g @codexhost/cli@1.2.4\nremote install\nremote stop\nremote start\n"
+ "{calls}view @karlorz/codexhost-cli@1.2.4 version --fetch-retries=0 --fetch-timeout=30000\nremote stop\ninstall -g @karlorz/codexhost-cli@1.2.4\nremote install\nremote stop\nremote start\n"
);
assert_eq!(std::fs::read_to_string(root.join("calls")).unwrap(), calls);
assert!(!root.join(".codexhost/ssh-setup.lock").exists());
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 2091b696f..f5abd35c3 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -30,4 +30,4 @@ After stopping CodexHost, you can delete its applicable local data. Native Harne
## Contact
-For non-sensitive privacy questions, open an issue at [BytePioneer-AI/codex-host](https://github.com/BytePioneer-AI/codex-host/issues). Do not include credentials, private code, or unredacted logs in public reports.
+For non-sensitive privacy questions, open an issue at [karlorz/codex-host](https://github.com/karlorz/codex-host/issues). Do not include credentials, private code, or unredacted logs in public reports.
diff --git a/docs/architecture/harness-executable-discovery.md b/docs/architecture/harness-executable-discovery.md
index 185b1af5b..05c43cbf9 100644
--- a/docs/architecture/harness-executable-discovery.md
+++ b/docs/architecture/harness-executable-discovery.md
@@ -28,7 +28,7 @@ packages/harness-discovery/
}
```
-该包参与 TypeScript 构建,但不会作为独立 npm 包发布。正式 npm 发布物仍是 `@codexhost/cli` 及对应的平台包;发现代码随 Host Runtime 和 Adapter 的发布产物一起交付。
+该包参与 TypeScript 构建,但不会作为独立 npm 包发布。正式 npm 发布物仍是 `@karlorz/codexhost-cli` 及对应的平台包;发现代码随 Host Runtime 和 Adapter 的发布产物一起交付。
## 公共能力
diff --git a/docs/index.md b/docs/index.md
index 5f51cb962..fb1c17f50 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -106,6 +106,7 @@
| [`product/codex-native-account-switching-design.md`](product/codex-native-account-switching-design.md) | 移除 Codex 多账号切换后的只读边界;修改 Codex 认证或账号路由时阅读。 |
| [`architecture/renderer-settings-styling.md`](architecture/renderer-settings-styling.md) | 设置页 Tailwind CSS 使用边界、构建方式与编写规则;新增或改版设置页界面时阅读。 |
| [`operations/codex-desktop-upgrade-diagnosis-playbook.md`](operations/codex-desktop-upgrade-diagnosis-playbook.md) | Desktop 更新后 Renderer、Bridge、Agent 和 Model 异常,以及输入性能的诊断流程;兼容性与交互性能回归时阅读。 |
+| [`operations/cpa-live-voice-desktop-hook.md`](operations/cpa-live-voice-desktop-hook.md) | Live-voice `clientOwnsCall` override (default on, Desktop 26.1007.30912 only); Settings → General switch, `CODEXHOST_CPA_LIVE_VOICE=0`, and `config.toml` realtime base URLs. |
## 平台、进程与远程运行
@@ -129,6 +130,7 @@
| [`operations/remote-thread-read.md`](operations/remote-thread-read.md) | 通过现有 Desktop 远程连接跨 Harness 读取会话;包含部署要求、分页、响应大小与超时限制。 |
| [`operations/host-runtime-log.md`](operations/host-runtime-log.md) | Host Runtime 日志文件的位置、内容与边界;排查 Runtime 崩溃或异常退出时阅读。 |
| [`operations/codexhost-console.md`](operations/codexhost-console.md) | 本地控制台的打开方式、页面、项目公告、与运行中 Host 的连接、端口、诊断文件、安全与更新边界;排查启动失败或维护 Web 公告时阅读。 |
+| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`…;npm 发 `@karlorz/codexhost-cli`;in-app Update 读 fork GitHub latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
| [`NOTICE.md`](NOTICE.md) | Web 控制台打开或刷新时读取的项目公告,含显示开关、标题、类型与正文;默认关闭,发布规则见控制台文档。 |
| [`operations/repository-maintenance.md`](operations/repository-maintenance.md) | PR 标签、CI 评论和发布前检查自动化;修改仓库自动化时阅读。 |
diff --git a/docs/operations/codexhost-console.md b/docs/operations/codexhost-console.md
index 0417e4d4c..6dfa4287c 100644
--- a/docs/operations/codexhost-console.md
+++ b/docs/operations/codexhost-console.md
@@ -2,11 +2,11 @@
控制台是一个本地网页,不依赖 Codex Desktop 启动成功。左侧导航分为:
-- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。
+- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。安装包上的 **codexhost 版本** 来自 `app/codexhost-distribution.json`。`release:prepare` 只同步 `package.json` / lock / Cargo;distribution 文件由随后的 `release:package` 或 `release:npm` 写出。本 fork 上该数字必须等于当前 fork 标签,例如 upstream 仍为 `0.13.2` 时显示 `0.13.2-0`,见 [fork-versioning.md](fork-versioning.md)。热装现有 app 时需重打包或直接改写已安装的 `codexhost-distribution.json`。
- **设置**:连接、远程连接、账号、会话导入、更新,与 Codex 设置页使用同一套页面和 Host 接口。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。
- **其他**:关于。
-外观(思考文本显示、空闲释放)由 Codex 界面保存,只能在 Codex 设置页修改。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
+外观(思考文本显示、空闲释放、改写实时语音)由 Codex 界面保存,只能在 Codex 设置页修改。本 fork 的改写实时语音默认开启;`~/.codex/config.toml` 里的 realtime 基址仍需自行配置。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
## 项目公告
diff --git a/docs/operations/cpa-live-voice-desktop-hook.md b/docs/operations/cpa-live-voice-desktop-hook.md
new file mode 100644
index 000000000..19e797509
--- /dev/null
+++ b/docs/operations/cpa-live-voice-desktop-hook.md
@@ -0,0 +1,46 @@
+# CPA live-voice Desktop hook
+
+Gated CodexHost Renderer hook that forces durable-only `clientOwnsCall` on official Codex Desktop **26.1007.30912**, so CPA can create the realtime call (`POST /v1/realtime/calls`) without rewriting `app.asar` or replacing `/Applications/ChatGPT.app`.
+
+## Default
+
+**On** in this fork. The Desktop Controller injects the hook unless `CODEXHOST_CPA_LIVE_VOICE=0`. That environment variable is **not live**: changing it requires relaunching Codex. Codex Settings → General has an **Override live voice** switch, also on by default (`localStorage` missing means on; `"0"` means off). The switch applies on the next voice overlay without relaunch. If the hook was not injected (`CODEXHOST_CPA_LIVE_VOICE=0`), the page shows a restart warning and the switch cannot take effect until relaunch. The unaudited-Desktop asset gate still fail-closes. The Web console does not duplicate this switch.
+
+The hook only changes Desktop call ownership. Point Codex at the realtime host in `~/.codex/config.toml` as well, then relaunch:
+
+```toml
+experimental_realtime_ws_base_url = "https:///v1"
+experimental_realtime_webrtc_call_base_url = "https:///v1"
+```
+
+## What it changes
+
+Desktop’s voice-start path sets `clientOwnsCall` when the `hostId` argument is `durable` **or** both of these hold: the host's app-server version supports the `threadRealtimeExistingCall` capability (a version comparison, not a stored flag), and Statsig gate `206132468` passes. That second branch POSTs to ChatGPT `/wham/realtime/calls` and then starts `thread/realtime/start` as `ExistingCall`. CPA never sees creation, so the sideband websocket 404s.
+
+The hook forces Statsig `checkGate("206132468")` to false on every client registered on `__STATSIG__`. In the audited build that gate has no other reader, so the result is durable-only ownership: a local host gets `clientOwnsCall=false` and `transport=webrtc`, and CPA can `POST /v1/realtime/calls`. Other gates pass through unchanged. Official app-server traffic is not rewritten. Host still passes official `thread/realtime/*` through.
+
+## How it is installed
+
+1. Unless `CODEXHOST_CPA_LIVE_VOICE=0`, the Controller prefixes the production Renderer source with a frozen `globalThis.__codexhostCpaLiveVoiceHookV1` record (audited Desktop version and `app-initial` asset name only).
+2. `Page.addScriptToEvaluateOnNewDocument` plus immediate `Runtime.evaluate` inject the Host Renderer into the primary `app://-/index.html` page.
+3. Voice starts on the avatar overlay (`app://-/index.html?initialRoute=/avatar-overlay`). That page gets a **hook-only** bundle (`cpa-live-voice-hook.js`), not the Host Renderer binding. The overlay loads the same audited `app-initial` asset in its own JavaScript realm with its own Statsig client.
+4. The Renderer hook no-ops unless the exact audited `app://-/assets/app-initial-6350d04672d0.js` asset is present (performance entries, script src, or link href, including relative URLs). Unknown assets fail closed; matching text in a query string or source-map filename is insufficient.
+5. On a matching page it polls for up to 60 seconds until a Statsig client is registered, then wraps that client's `checkGate`. The gate is read when voice starts, so the wrap only has to land before the first voice click.
+
+Verify before a trial: on the overlay, `__STATSIG__.firstInstance.checkGate("206132468")` returns `false`.
+
+Overlay discovery, connection, and injection share one timeout. Overlay attempts run concurrently, and a stalled or disappearing overlay does not prevent the primary Host session from becoming ready after that deadline.
+
+The hook does not copy OpenAI frontend JavaScript into git. Tests cover the asset gate and Statsig override with synthetic clients.
+
+## Version gate
+
+Audited only for Desktop **26.1007.30912** (primary bundle `app-initial-6350d04672d0.js`). Re-audit before enabling on a newer build. Fail closed: no wrap, no ASAR patch, keep `~/Applications/ChatGPT-Voice.app` if that copy is still the working path.
+
+## Invariants
+
+- Do not overwrite `/Applications/ChatGPT.app` (Developer ID `2DC432GLL2`).
+- Do not reuse `install_voice_patch.py`.
+- Do not quit or replace the working Voice copy until the CodexHost path proves `POST /v1/realtime/calls` 201.
+- Do not put call IDs, SDP, or auth material in docs, git, or command output.
+- Do not `git push` this branch or open an upstream PR unless explicitly requested.
diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md
new file mode 100644
index 000000000..1fbf5a2c7
--- /dev/null
+++ b/docs/operations/fork-versioning.md
@@ -0,0 +1,89 @@
+# Fork versioning (`karlorz/codex-host`)
+
+This checkout tracks [BytePioneer-AI/codex-host](https://github.com/BytePioneer-AI/codex-host) as `upstream` and publishes only to the fork [karlorz/codex-host](https://github.com/karlorz/codex-host). It does not define upstream BytePioneer release policy.
+
+Package identity follows the same fork-scope rule as other karlorz forks: a permanent `@karlorz/` rename, not a publish-time override, and not `@codexhost/*`.
+
+## Tag rule
+
+Fork versions stay on the **upstream version that is currently held**, plus a numeric fork counter.
+
+| Upstream holds | First fork tag | Next fork tags |
+| --- | --- | --- |
+| `0.13.2` | `v0.13.2-0` | `v0.13.2-1`, `v0.13.2-2`, … |
+| later `0.13.3` | `v0.13.3-0` | `v0.13.3-1`, … |
+
+- Read upstream from `upstream/main` `package.json` and the upstream GitHub latest release. If they disagree, stop and report; do not guess.
+- `n` is the next unused non-negative integer for that upstream version on the fork (`0` if none exist).
+- Do **not** invent a newer minor or patch than upstream (for example `0.13.3-cpa.1` while upstream still holds `0.13.2`).
+- `v0.13.2-0` is valid SemVer (prerelease `0` of `0.13.2`). Prepare it with `npm run release:prepare -- 0.13.2-0`.
+- Numeric prerelease `0.13.2-0` compares **below** upstream `0.13.2`. The in-app updater and Settings command must therefore **not** read BytePioneer `releases/latest` or install `@codexhost/cli`. They use `karlorz/codex-host` and `@karlorz/codexhost-cli` instead.
+
+The four prepared files (`package.json`, `package-lock.json`, `Cargo.toml`, `Cargo.lock`) must equal that version. Packaged `app/codexhost-distribution.json` is written later by `release:package` / `release:npm`, not by `release:prepare`. The Web console overview **codexhost 版本** reads that distribution file (installer/npm) or workspace `package.json` (source). After prepare, rebuild or recopy the installed `codexhost-distribution.json` so the live console matches the tag.
+
+## npm and in-app updates
+
+Fork operators install and update with:
+
+```bash
+npm install -g @karlorz/codexhost-cli@latest
+```
+
+That is the Settings → Updates manual command. **Update and restart** on an npm install runs the same package at the GitHub latest version (via the Updater), not `@codexhost/cli`.
+
+| Channel | Fork identity | Do not use |
+| --- | --- | --- |
+| npm meta package | `@karlorz/codexhost-cli` | `@codexhost/cli` |
+| npm platform packages | `@karlorz/codexhost-cli-darwin-arm64` (and the other five) | `@codexhost/cli-*` |
+| GitHub latest / notes / assets | `karlorz/codex-host` | `BytePioneer-AI/codex-host` |
+
+A fork tag is not shipped until both of these exist for that version:
+
+1. **npm:** attended `npm publish` of the packed meta package and matching platform tarballs under the `@karlorz` scope, dist-tag `latest` (or `next` only if the tag is an npm prerelease channel). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`.
+2. **GitHub:** a **latest** (non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build.
+
+Keep GitHub Actions workflow `Release packages` **disabled** until it is wired to publish `@karlorz/*` with a Trusted Publisher on `karlorz/codex-host`. Do not enable it while it would still publish `@codexhost/*`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
+
+## GitHub Actions secrets
+
+CI and `Release packages` already use the job `GITHUB_TOKEN` (`contents` / `id-token: write`). Do not add `secrets.NPM_TOKEN` or `NODE_AUTH_TOKEN` to those workflows: npm publish is Trusted Publisher (`id-token: write` + `--provenance`), same as upstream.
+
+Store these repo secrets on `karlorz/codex-host` for attended and future fork-sync work, matching the other karlorz forks:
+
+| Secret | Same as | Purpose |
+| --- | --- | --- |
+| `NPM_TOKEN` | `karlorz/llm-wiki` | Operator/local `npm publish` of `@karlorz/*` when OIDC is not in play. Not read by current CI YAML. |
+| `FORK_SYNC_TOKEN` | `karlorz/orca` | Classic PAT with `repo` + `workflow`. Needed later for `merge-upstream` / pushing workflow-file updates; `GITHUB_TOKEN` cannot do that. `karlorz/cliproxyapi` names the same kind of PAT `UPSTREAM_SYNC_PUSH_TOKEN`. |
+
+```bash
+# npmjs auth token from ~/.npmrc, value never printed
+python3 -c 'from pathlib import Path
+for line in Path.home().joinpath(".npmrc").read_text().splitlines():
+ if line.startswith("//registry.npmjs.org/:_authToken="):
+ print(line.split("=",1)[1], end="")
+ break
+' | gh secret set NPM_TOKEN -R karlorz/codex-host
+
+# classic PAT (repo + workflow), not a gh oauth gho_ token
+gh secret set FORK_SYNC_TOKEN -R karlorz/codex-host
+```
+
+Keep `Release packages` disabled until npm Trusted Publisher on `@karlorz/codexhost-cli` (and the six platform packages) is the karlorz GitHub repo.
+
+## Release channel on this fork
+
+- Never `git push` to `upstream`.
+- Never publish `@codexhost/*` from this checkout.
+- Do not move, delete, or re-push an existing tag unless the operator explicitly requested that rewrite.
+- Create GitHub Releases with `-R karlorz/codex-host`. `gh release create --notes-from-tag` does not work with `--repo`; write the tag annotation to a notes file instead. A `gh` invocation without `-R` may resolve to BytePioneer and must abort.
+
+## Next unused counter
+
+```bash
+git fetch upstream --tags --prune
+git fetch origin --tags --prune
+# example when upstream holds 0.13.2:
+git ls-remote --tags origin 'refs/tags/v0.13.2-*'
+```
+
+Pick the smallest `n` that is not already a fork tag. Then `npm run release:prepare -- 0.13.2-` on a branch, with the usual release-skill checks before any tag push.
diff --git a/docs/platforms/linux/linux.md b/docs/platforms/linux/linux.md
index 279023de3..ed6febfbd 100644
--- a/docs/platforms/linux/linux.md
+++ b/docs/platforms/linux/linux.md
@@ -3,7 +3,7 @@
codexhost supports x64 and ARM64 Linux through the npm package. Install the official ChatGPT App matching the current architecture first, then install codexhost:
```bash
-npm install -g @codexhost/cli
+npm install -g @karlorz/codexhost-cli
codexhost
```
diff --git a/docs/platforms/linux/linux.zh-CN.md b/docs/platforms/linux/linux.zh-CN.md
index 01b4d5856..d3dc1ca2c 100644
--- a/docs/platforms/linux/linux.zh-CN.md
+++ b/docs/platforms/linux/linux.zh-CN.md
@@ -3,7 +3,7 @@
codexhost 通过 npm 包支持 x64 和 ARM64 Linux。请先安装与当前架构匹配的官方 ChatGPT App,再安装 codexhost:
```bash
-npm install -g @codexhost/cli
+npm install -g @karlorz/codexhost-cli
codexhost
```
diff --git a/docs/platforms/remote/remote-ssh-host.md b/docs/platforms/remote/remote-ssh-host.md
index 6cfdcf724..7937a213e 100644
--- a/docs/platforms/remote/remote-ssh-host.md
+++ b/docs/platforms/remote/remote-ssh-host.md
@@ -90,7 +90,7 @@ Older remote installations cannot report their version; the page shows **Update
```bash
codexhost remote stop
-npm install -g @codexhost/cli@latest
+npm install -g @karlorz/codexhost-cli@latest
codexhost remote install
codexhost remote start
```
@@ -139,7 +139,7 @@ This applies to external Harness conversations created in SSH projects. Other lo
Choose **Uninstall remote service** on the connection card. After confirmation, auto-connect is disabled for this connection, the remote service stops and its connection setup is removed. On Mac, the associated Harness background service is also uninstalled. The SSH connection entry, project files and chat data are kept, so the service can be installed again later. Active remote sessions are interrupted.
-The codexhost package is kept by default. Select **Also uninstall the codexhost package** to remove the remote computer's global npm `@codexhost/cli` after service removal succeeds. Codex CLI, Node.js and other Harnesses are kept. Only select this if the remote Desktop no longer needs that package to launch. A mismatch between the active CLI and npm's global installation rejects package removal before stopping the service; choose service-only removal or uninstall the package manually on the remote computer. If package removal fails after service removal, the page explains the partial result and allows retrying.
+The codexhost package is kept by default. Select **Also uninstall the codexhost package** to remove the remote computer's global npm `@karlorz/codexhost-cli` after service removal succeeds. Codex CLI, Node.js and other Harnesses are kept. Only select this if the remote Desktop no longer needs that package to launch. A mismatch between the active CLI and npm's global installation rejects package removal before stopping the service; choose service-only removal or uninstall the package manually on the remote computer. If package removal fails after service removal, the page explains the partial result and allows retrying.
When you no longer need the remote installation, run these commands on the remote computer:
diff --git a/docs/platforms/remote/remote-ssh-host.zh-CN.md b/docs/platforms/remote/remote-ssh-host.zh-CN.md
index e4bb2e6c2..19331ea15 100644
--- a/docs/platforms/remote/remote-ssh-host.zh-CN.md
+++ b/docs/platforms/remote/remote-ssh-host.zh-CN.md
@@ -90,7 +90,7 @@ Web 控制台的「远程连接」(`#remote-connections`)也提供同一套
```bash
codexhost remote stop
-npm install -g @codexhost/cli@latest
+npm install -g @karlorz/codexhost-cli@latest
codexhost remote install
codexhost remote start
```
@@ -139,7 +139,7 @@ npm start 0.12.0
在连接卡片中点击「卸载远程服务」。确认后,会关闭此连接的自动连接、停止远程服务并移除远程接入配置;Mac 上还会卸载配套的 Harness 后台服务。SSH 连接记录、项目文件和会话数据保留,之后可重新安装服务。卸载会中断正在进行的远程会话。
-默认保留 codexhost 软件包。勾选「同时卸载 codexhost 软件包」会在服务卸载成功后,再卸载远程电脑的 npm 全局 `@codexhost/cli`,不会卸载 Codex CLI、Node.js 或其他 Harness。勾选前请确认远程桌面端不再需要通过该软件包启动。安装位置与当前 npm 不匹配时,软件包卸载会在停止服务前被拒绝;可改为仅卸载服务,或在远程电脑上手动卸载软件包。软件包卸载失败时,页面会明确提示远程服务已卸载,并允许重试。
+默认保留 codexhost 软件包。勾选「同时卸载 codexhost 软件包」会在服务卸载成功后,再卸载远程电脑的 npm 全局 `@karlorz/codexhost-cli`,不会卸载 Codex CLI、Node.js 或其他 Harness。勾选前请确认远程桌面端不再需要通过该软件包启动。安装位置与当前 npm 不匹配时,软件包卸载会在停止服务前被拒绝;可改为仅卸载服务,或在远程电脑上手动卸载软件包。软件包卸载失败时,页面会明确提示远程服务已卸载,并允许重试。
不再使用远程端时,在远程电脑的终端执行:
diff --git a/docs/project/README.ko.md b/docs/project/README.ko.md
index 9d6ca4fe9..0617c9c03 100644
--- a/docs/project/README.ko.md
+++ b/docs/project/README.ko.md
@@ -30,7 +30,7 @@
-다운로드 · Agent 간 협업 · 원격 연결 · 微信交流群 · 简体中文 · English
+다운로드 · Agent 간 협업 · 원격 연결 · 微信交流群 · 简体中文 · English
@@ -66,13 +66,13 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76
**방법 1: npm** (macOS / Windows / Linux)
```bash
-npm install -g @codexhost/cli
+npm install -g @karlorz/codexhost-cli
codexhost
```
**방법 2: 설치 프로그램** (macOS / Windows)
-[Releases](https://github.com/BytePioneer-AI/codex-host/releases)에서 플랫폼에 맞는 설치 프로그램을 다운로드하세요.
+[Releases](https://github.com/karlorz/codex-host/releases)에서 플랫폼에 맞는 설치 프로그램을 다운로드하세요.
> Linux는 x64 / ARM64를 지원합니다. 자세한 내용은 [Linux 안내](../platforms/linux/linux.md)를 참고하세요.
@@ -222,7 +222,7 @@ CodexHost는 대상 Harness를 위한 별도의 Native Session을 만듭니다.
1. 제어 대상 컴퓨터에서 설치하고 시작합니다:
```bash
- npm install -g @codexhost/cli
+ npm install -g @karlorz/codexhost-cli
codexhost remote install
codexhost remote start
codexhost remote status
@@ -287,7 +287,7 @@ Issue나 PR을 제출하기 전에 [기여 안내](../../CONTRIBUTING.md)를 읽
환경 요구 사항: 공식 Codex Desktop, Node.js 22.19+ 또는 24, Rust.
```bash
-git clone https://github.com/BytePioneer-AI/codex-host
+git clone https://github.com/karlorz/codex-host
cd codex-host
npm ci
npm start
@@ -315,8 +315,8 @@ Harness를 추가할 때는 코딩 Agent가 저장소의 [codexhost-add-harness
CodexHost에 기여해 주신 모든 분께 감사드립니다.
-
-
+
+
## Star History
diff --git a/docs/project/README.zh-CN.md b/docs/project/README.zh-CN.md
index 65a7d8deb..718ea28f8 100644
--- a/docs/project/README.zh-CN.md
+++ b/docs/project/README.zh-CN.md
@@ -30,7 +30,7 @@
-下载 · 跨 Agent 协作 · 远程连接 · 微信交流群 · English · 한국어
+下载 · 跨 Agent 协作 · 远程连接 · 微信交流群 · English · 한국어
@@ -66,13 +66,13 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76
**方式一:npm**(macOS / Windows / Linux)
```bash
-npm install -g @codexhost/cli
+npm install -g @karlorz/codexhost-cli
codexhost
```
**方式二:安装包**(macOS / Windows)
-从 [Releases](https://github.com/BytePioneer-AI/codex-host/releases) 下载对应平台的安装包。
+从 [Releases](https://github.com/karlorz/codex-host/releases) 下载对应平台的安装包。
> Linux 支持 x64 / ARM64,详见 [Linux 说明](../platforms/linux/linux.zh-CN.md)。
@@ -225,7 +225,7 @@ CodexHost 会为目标 Harness 创建独立的 Native Session。委派会话将
1. 在被控机器上安装并启动:
```bash
- npm install -g @codexhost/cli
+ npm install -g @karlorz/codexhost-cli
codexhost remote install
codexhost remote start
codexhost remote status
@@ -290,7 +290,7 @@ CodexHost 的做法不同:
环境要求:官方 Codex Desktop、Node.js 22.19+ 或 24、Rust。
```bash
-git clone https://github.com/BytePioneer-AI/codex-host
+git clone https://github.com/karlorz/codex-host
cd codex-host
npm ci
npm start
@@ -321,8 +321,8 @@ npm start
感谢所有为 CodexHost 做出贡献的开发者。
-
-
+
+
## Star History
diff --git a/package-lock.json b/package-lock.json
index f754edd8e..f4b7766f7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexhost",
- "version": "0.13.2",
+ "version": "0.13.2-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexhost",
- "version": "0.13.2",
+ "version": "0.13.2-1",
"license": "LGPL-3.0-only",
"workspaces": [
"packages/*",
diff --git a/package.json b/package.json
index 5dd761af7..6d9be7eb2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexhost",
- "version": "0.13.2",
+ "version": "0.13.2-1",
"private": true,
"license": "LGPL-3.0-only",
"type": "module",
diff --git a/packages/console-server/src/diagnostic-report.ts b/packages/console-server/src/diagnostic-report.ts
index a77e914d0..bdd227d86 100644
--- a/packages/console-server/src/diagnostic-report.ts
+++ b/packages/console-server/src/diagnostic-report.ts
@@ -8,7 +8,7 @@ import type {
} from "./diagnostics.js";
import type { InspectDocument } from "./installation.js";
-export const ISSUE_REPOSITORY_URL = "https://github.com/BytePioneer-AI/codex-host";
+export const ISSUE_REPOSITORY_URL = "https://github.com/karlorz/codex-host";
const ISSUE_ERROR_CHARS = 800;
const ISSUE_URL_MAX_LENGTH = 7_000;
export const REPORT_LOG_TAIL_BYTES = 32 * 1024;
diff --git a/packages/console-server/test/server.test.ts b/packages/console-server/test/server.test.ts
index abddd4c75..b8c7682d7 100644
--- a/packages/console-server/test/server.test.ts
+++ b/packages/console-server/test/server.test.ts
@@ -167,7 +167,7 @@ describe("console server", () => {
expect(overview.summary.state).toBe("stopped");
expect(overview.inspect.desktop?.version).toBe("26.924.20706");
expect(overview.issueUrl).toMatch(
- /^https:\/\/github\.com\/BytePioneer-AI\/codex-host\/issues\/new\?/u,
+ /^https:\/\/github\.com\/karlorz\/codex-host\/issues\/new\?/u,
);
expect(decodeURIComponent(overview.issueUrl)).toContain("26.924.20706");
});
diff --git a/packages/console-server/test/updates.test.ts b/packages/console-server/test/updates.test.ts
index 2bfd1773c..0077591cf 100644
--- a/packages/console-server/test/updates.test.ts
+++ b/packages/console-server/test/updates.test.ts
@@ -21,7 +21,7 @@ afterEach(async () => {
const release = {
version: "1.1.0",
releaseNotes: "Fixes",
- releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.1.0",
+ releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.1.0",
assets: [],
};
diff --git a/packages/desktop-control/src/cpa-live-voice-hook.ts b/packages/desktop-control/src/cpa-live-voice-hook.ts
new file mode 100644
index 000000000..727f7bfd3
--- /dev/null
+++ b/packages/desktop-control/src/cpa-live-voice-hook.ts
@@ -0,0 +1,17 @@
+export const CPA_LIVE_VOICE_ENV = "CODEXHOST_CPA_LIVE_VOICE";
+export const CPA_LIVE_VOICE_HOOK_GLOBAL = "__codexhostCpaLiveVoiceHookV1";
+export const AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION = "26.1007.30912";
+export const AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET = "app-initial-6350d04672d0.js";
+
+export function cpaLiveVoiceHookEnabled(environment: NodeJS.ProcessEnv = process.env): boolean {
+ return environment[CPA_LIVE_VOICE_ENV] !== "0";
+}
+
+export function cpaLiveVoiceHookBootstrap(environment: NodeJS.ProcessEnv = process.env): string {
+ if (!cpaLiveVoiceHookEnabled(environment)) return "";
+ return `globalThis.${CPA_LIVE_VOICE_HOOK_GLOBAL} = Object.freeze(${JSON.stringify({
+ enabled: true,
+ auditedDesktopVersion: AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION,
+ auditedAppInitialAsset: AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
+ })});\n`;
+}
diff --git a/packages/desktop-control/src/index.ts b/packages/desktop-control/src/index.ts
index cb842a2a6..57fa52b46 100644
--- a/packages/desktop-control/src/index.ts
+++ b/packages/desktop-control/src/index.ts
@@ -30,6 +30,7 @@ export { inspectRendererDom, validateRendererDomInspection } from "./renderer-do
export {
createRendererCdpControlSession,
installRendererCdpControlSession,
+ selectAvatarOverlayRendererTargets,
selectPrimaryRendererTarget,
} from "./renderer-cdp-control-session.js";
export type {
diff --git a/packages/desktop-control/src/production-controller.ts b/packages/desktop-control/src/production-controller.ts
index 0dbf76ca7..953f0f14b 100644
--- a/packages/desktop-control/src/production-controller.ts
+++ b/packages/desktop-control/src/production-controller.ts
@@ -15,6 +15,7 @@ import {
createRendererStatusReporter,
type DesktopControllerStatusDocument,
} from "./controller-status.js";
+import { cpaLiveVoiceHookBootstrap } from "./cpa-live-voice-hook.js";
import {
installRendererCdpControlSession,
type RendererCdpControlSession,
@@ -38,6 +39,7 @@ export interface DesktopControllerDependencies {
install(options: {
rendererCdpEndpoint: string;
rendererSource: string;
+ overlayHookSource?: string;
enabledAgents?: readonly string[];
timeoutMs: number;
signal?: AbortSignal;
@@ -241,13 +243,25 @@ export async function runDesktopController(
const createSession = async (): Promise => {
status.installing();
startupTrace("reading Renderer bundle");
- const rendererSource = await dependencies.readRenderer(options.rendererPath);
+ const rendererRead = dependencies.readRenderer(options.rendererPath);
+ const bootstrap = cpaLiveVoiceHookBootstrap();
+ const overlayPath = bootstrap
+ ? path.join(path.dirname(options.rendererPath), "cpa-live-voice-hook.js")
+ : undefined;
+ const [rendererSource, overlayBundle] = overlayPath
+ ? await Promise.all([rendererRead, dependencies.readRenderer(overlayPath)])
+ : [await rendererRead, undefined];
if (rendererSource.trim().length === 0) throw new Error("production Renderer Bundle is empty");
+ const overlayHookSource =
+ bootstrap && overlayBundle && overlayBundle.trim().length > 0
+ ? `${bootstrap}${overlayBundle}`
+ : undefined;
startupTrace("installing Renderer Session");
const installed = await installProductionSession(
{
rendererCdpEndpoint: options.rendererCdpEndpoint,
- rendererSource: `${RENDERER_CSP_BOOTSTRAP}\n${rendererSource}`,
+ rendererSource: `${RENDERER_CSP_BOOTSTRAP}\n${bootstrap}${rendererSource}`,
+ ...(overlayHookSource ? { overlayHookSource } : {}),
// External Agents are discovered from each target Host after installation.
timeoutMs: PRODUCTION_INSTALL_TIMEOUT_MS,
signal,
diff --git a/packages/desktop-control/src/renderer-cdp-control-session.ts b/packages/desktop-control/src/renderer-cdp-control-session.ts
index 85e76de67..a99b8de98 100644
--- a/packages/desktop-control/src/renderer-cdp-control-session.ts
+++ b/packages/desktop-control/src/renderer-cdp-control-session.ts
@@ -6,6 +6,7 @@ import {
type CdpTarget,
cdpExceptionMessage,
} from "./cdp-client.js";
+import { CPA_LIVE_VOICE_HOOK_GLOBAL } from "./cpa-live-voice-hook.js";
import {
installRendererDraftPrewarmPolicyDirect,
type RendererDraftPrewarmPolicyStatus,
@@ -49,6 +50,7 @@ export interface RendererCdpControlSession {
export interface InstallRendererCdpControlOptions {
rendererCdpEndpoint: string;
rendererSource: string;
+ overlayHookSource?: string;
enabledAgents?: readonly string[];
pollIntervalMs?: number;
timeoutMs?: number;
@@ -124,6 +126,19 @@ function isPrimaryRendererUrl(value: string): boolean {
}
}
+function isAvatarOverlayRendererUrl(value: string): boolean {
+ try {
+ const url = new URL(value);
+ if (url.protocol !== "app:" || url.hostname !== "-") return false;
+ if (url.pathname === "/avatar-overlay.html") return true;
+ return (
+ url.pathname === "/index.html" && url.searchParams.get("initialRoute") === "/avatar-overlay"
+ );
+ } catch {
+ return false;
+ }
+}
+
export function selectPrimaryRendererTarget(
targets: readonly CdpTarget[],
preferredTargetId?: string,
@@ -134,6 +149,10 @@ export function selectPrimaryRendererTarget(
return candidates.find((target) => target.id === preferredTargetId) ?? candidates.at(0) ?? null;
}
+export function selectAvatarOverlayRendererTargets(targets: readonly CdpTarget[]): CdpTarget[] {
+ return targets.filter((item) => item.type === "page" && isAvatarOverlayRendererUrl(item.url));
+}
+
class RendererAdapterReadinessError extends Error {
constructor(
readonly state: string,
@@ -249,6 +268,71 @@ async function waitForBinding(
throw new Error(`Production Renderer binding did not become ready${detail}`);
}
+async function overlayHookAlreadyEnabled(renderer: RendererCdpClient): Promise {
+ try {
+ const value = await renderer.evaluate(
+ `globalThis.${CPA_LIVE_VOICE_HOOK_GLOBAL}?.enabled === true`,
+ );
+ return value === true;
+ } catch {
+ return false;
+ }
+}
+
+async function installOverlayHook(
+ target: CdpTarget,
+ overlayHookSource: string,
+ operations: RendererCdpControlOperations,
+ signal: AbortSignal,
+): Promise {
+ const run = (operation: () => Promise): Promise => {
+ signal.throwIfAborted();
+ return awaitWithSignal(operation(), signal, "Overlay hook installation timed out");
+ };
+ const renderer = await run(async () => {
+ const connected = await operations.connect(target.webSocketDebuggerUrl);
+ if (signal.aborted) {
+ connected.close();
+ signal.throwIfAborted();
+ }
+ return connected;
+ });
+ try {
+ await run(() => renderer.command("Runtime.enable"));
+ await run(() => renderer.command("Page.enable"));
+ if (await run(() => overlayHookAlreadyEnabled(renderer))) return;
+ await run(() =>
+ renderer.command("Page.addScriptToEvaluateOnNewDocument", { source: overlayHookSource }),
+ );
+ await run(() => evaluateSource(renderer, overlayHookSource));
+ } finally {
+ renderer.close();
+ }
+}
+
+async function installOverlayHooks(
+ endpoint: string,
+ overlayHookSource: string | undefined,
+ operations: RendererCdpControlOperations,
+ signal: AbortSignal,
+): Promise {
+ if (!overlayHookSource) return;
+ try {
+ const overlays = selectAvatarOverlayRendererTargets(
+ await awaitWithSignal(
+ operations.listTargets(endpoint, signal),
+ signal,
+ "Overlay CDP target discovery timed out",
+ ),
+ );
+ await Promise.allSettled(
+ overlays.map((overlay) => installOverlayHook(overlay, overlayHookSource, operations, signal)),
+ );
+ } catch {
+ // Overlay discovery and injection are best-effort and share one deadline.
+ }
+}
+
async function installTarget(
target: CdpTarget,
rendererSource: string,
@@ -279,6 +363,7 @@ class InstalledRendererCdpControlSession implements RendererCdpControlSession {
private renderer: RendererCdpClient,
private readonly rendererCdpEndpoint: string,
private readonly rendererSource: string,
+ private readonly overlayHookSource: string | undefined,
private readonly enabledAgents: readonly string[] | undefined,
private readonly timeoutMs: number,
private readonly pollIntervalMs: number,
@@ -311,37 +396,41 @@ class InstalledRendererCdpControlSession implements RendererCdpControlSession {
this.renderer.close();
this.renderer = replacement.renderer;
this.currentSnapshot = replacement.snapshot;
- return this.currentSnapshot;
- }
-
- try {
- const existing = await readBinding(this.renderer);
- if (existing === null) await evaluateSource(this.renderer, this.rendererSource);
- // A Host switch can temporarily leave the Adapter installing. Reconcile
- // native owners before validating readiness; that is not a lost CDP page.
- const draftPrewarmPolicy = await this.operations.installDraftPrewarmPolicy(this.renderer);
- const binding = await waitForBinding(
- this.renderer,
- this.enabledAgents,
- this.timeoutMs,
- this.pollIntervalMs,
- );
- this.currentSnapshot = { target, draftPrewarmPolicy, binding };
- return this.currentSnapshot;
- } catch {
- const replacement = await installTarget(
- target,
- this.rendererSource,
- this.enabledAgents,
- this.timeoutMs,
- this.pollIntervalMs,
- this.operations,
- );
- this.renderer.close();
- this.renderer = replacement.renderer;
- this.currentSnapshot = replacement.snapshot;
- return this.currentSnapshot;
+ } else {
+ try {
+ const existing = await readBinding(this.renderer);
+ if (existing === null) await evaluateSource(this.renderer, this.rendererSource);
+ // A Host switch can temporarily leave the Adapter installing. Reconcile
+ // native owners before validating readiness; that is not a lost CDP page.
+ const draftPrewarmPolicy = await this.operations.installDraftPrewarmPolicy(this.renderer);
+ const binding = await waitForBinding(
+ this.renderer,
+ this.enabledAgents,
+ this.timeoutMs,
+ this.pollIntervalMs,
+ );
+ this.currentSnapshot = { target, draftPrewarmPolicy, binding };
+ } catch {
+ const replacement = await installTarget(
+ target,
+ this.rendererSource,
+ this.enabledAgents,
+ this.timeoutMs,
+ this.pollIntervalMs,
+ this.operations,
+ );
+ this.renderer.close();
+ this.renderer = replacement.renderer;
+ this.currentSnapshot = replacement.snapshot;
+ }
}
+ await installOverlayHooks(
+ this.rendererCdpEndpoint,
+ this.overlayHookSource,
+ this.operations,
+ discoveryTimeoutSignal(this.timeoutMs),
+ );
+ return this.currentSnapshot;
}
async activateDesktop(): Promise {
@@ -391,10 +480,17 @@ export async function createRendererCdpControlSession(
pollIntervalMs,
operations,
);
+ await installOverlayHooks(
+ options.rendererCdpEndpoint,
+ options.overlayHookSource,
+ operations,
+ discoveryTimeoutSignal(timeoutMs, options.signal),
+ );
return new InstalledRendererCdpControlSession(
installed.renderer,
options.rendererCdpEndpoint,
options.rendererSource,
+ options.overlayHookSource,
enabledAgents,
timeoutMs,
pollIntervalMs,
diff --git a/packages/desktop-control/test/cpa-live-voice-hook.test.ts b/packages/desktop-control/test/cpa-live-voice-hook.test.ts
new file mode 100644
index 000000000..cd3ff373a
--- /dev/null
+++ b/packages/desktop-control/test/cpa-live-voice-hook.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
+ AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION,
+ CPA_LIVE_VOICE_ENV,
+ cpaLiveVoiceHookBootstrap,
+ cpaLiveVoiceHookEnabled,
+} from "../src/cpa-live-voice-hook.js";
+
+describe("CPA live-voice controller bootstrap", () => {
+ it("defaults on and opts out only with CODEXHOST_CPA_LIVE_VOICE=0", () => {
+ expect(cpaLiveVoiceHookEnabled({})).toBe(true);
+ expect(cpaLiveVoiceHookEnabled({ [CPA_LIVE_VOICE_ENV]: "1" })).toBe(true);
+ expect(cpaLiveVoiceHookEnabled({ [CPA_LIVE_VOICE_ENV]: "0" })).toBe(false);
+ expect(cpaLiveVoiceHookBootstrap({ [CPA_LIVE_VOICE_ENV]: "0" })).toBe("");
+ });
+
+ it("emits a frozen enable record by default", () => {
+ const source = cpaLiveVoiceHookBootstrap({});
+ expect(source.startsWith("globalThis.__codexhostCpaLiveVoiceHookV1 = Object.freeze(")).toBe(
+ true,
+ );
+ expect(source).toContain(`"auditedDesktopVersion":"${AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION}"`);
+ expect(source).toContain(
+ `"auditedAppInitialAsset":"${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}"`,
+ );
+ expect(source).toContain('"enabled":true');
+ expect(source).not.toContain("clientOwnsCall");
+ expect(source).not.toContain("function pa");
+ });
+});
diff --git a/packages/desktop-control/test/production-controller.test.ts b/packages/desktop-control/test/production-controller.test.ts
index a55a275ff..68f4633ee 100644
--- a/packages/desktop-control/test/production-controller.test.ts
+++ b/packages/desktop-control/test/production-controller.test.ts
@@ -113,7 +113,7 @@ describe("production Desktop Controller", () => {
close,
};
const ready = vi.fn();
- const install = vi.fn(async () => session);
+ const install = vi.fn(async () => session);
const server = attachmentServer();
let attach: (() => Promise) | undefined;
const startAttachmentServer = vi.fn(async (options) => {
@@ -131,13 +131,20 @@ describe("production Desktop Controller", () => {
await runDesktopController(controllerOptions(), abort.signal, dependencies);
- expect(install).toHaveBeenCalledWith({
- rendererCdpEndpoint: "http://127.0.0.1:43123",
- rendererSource:
- "globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;\nproduction renderer",
- timeoutMs: 90_000,
- signal: abort.signal,
- });
+ expect(install).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rendererCdpEndpoint: "http://127.0.0.1:43123",
+ timeoutMs: 90_000,
+ signal: abort.signal,
+ }),
+ );
+ const source = install.mock.calls[0]?.[0].rendererSource;
+ expect(source).toContain(
+ "globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;",
+ );
+ expect(source).toContain("production renderer");
+ expect(source).toContain("__codexhostCpaLiveVoiceHookV1");
+ expect(install.mock.calls[0]?.[0].overlayHookSource).toContain("__codexhostCpaLiveVoiceHookV1");
expect(startAttachmentServer).toHaveBeenCalledWith({
port: 43124,
nonce: attachmentNonce,
@@ -156,6 +163,83 @@ describe("production Desktop Controller", () => {
expect(attach).toEqual(expect.any(Function));
});
+ it("prefixes the CPA live-voice hook by default", async () => {
+ const previous = process.env.CODEXHOST_CPA_LIVE_VOICE;
+ delete process.env.CODEXHOST_CPA_LIVE_VOICE;
+ const abort = new AbortController();
+ const snapshot = controllerSnapshot();
+ const session: RendererCdpControlSession = {
+ snapshot,
+ ensureInstalled: vi.fn(async () => {
+ abort.abort();
+ return snapshot;
+ }),
+ activateDesktop: vi.fn(async () => 1),
+ executeRenderer: vi.fn(),
+ close: vi.fn(),
+ };
+ const install = vi.fn(async () => session);
+ try {
+ await runDesktopController(controllerOptions(), abort.signal, {
+ readRenderer: vi.fn(async (filePath: string) =>
+ filePath.endsWith("cpa-live-voice-hook.js") ? "overlay hook" : "production renderer",
+ ),
+ install,
+ startAttachmentServer: vi.fn(async () => attachmentServer()),
+ ready: vi.fn(),
+ sleep: vi.fn(async () => {}),
+ monitorIntervalMs: 1,
+ });
+ const source = install.mock.calls[0]?.[0].rendererSource;
+ expect(source).toContain("globalThis.__codexhostCpaLiveVoiceHookV1 = Object.freeze(");
+ expect(source).toContain("26.1007.30912");
+ expect(source?.endsWith("production renderer")).toBe(true);
+ expect(install.mock.calls[0]?.[0].overlayHookSource).toContain("overlay hook");
+ expect(install.mock.calls[0]?.[0].overlayHookSource).toContain(
+ "globalThis.__codexhostCpaLiveVoiceHookV1 = Object.freeze(",
+ );
+ } finally {
+ if (previous === undefined) delete process.env.CODEXHOST_CPA_LIVE_VOICE;
+ else process.env.CODEXHOST_CPA_LIVE_VOICE = previous;
+ }
+ });
+
+ it("omits the CPA live-voice hook when CODEXHOST_CPA_LIVE_VOICE=0", async () => {
+ const previous = process.env.CODEXHOST_CPA_LIVE_VOICE;
+ process.env.CODEXHOST_CPA_LIVE_VOICE = "0";
+ const abort = new AbortController();
+ const snapshot = controllerSnapshot();
+ const session: RendererCdpControlSession = {
+ snapshot,
+ ensureInstalled: vi.fn(async () => {
+ abort.abort();
+ return snapshot;
+ }),
+ activateDesktop: vi.fn(async () => 1),
+ executeRenderer: vi.fn(),
+ close: vi.fn(),
+ };
+ const install = vi.fn(async () => session);
+ try {
+ await runDesktopController(controllerOptions(), abort.signal, {
+ readRenderer: vi.fn(async (filePath: string) =>
+ filePath.endsWith("cpa-live-voice-hook.js") ? "overlay hook" : "production renderer",
+ ),
+ install,
+ startAttachmentServer: vi.fn(async () => attachmentServer()),
+ ready: vi.fn(),
+ sleep: vi.fn(async () => {}),
+ monitorIntervalMs: 1,
+ });
+ const source = install.mock.calls[0]?.[0].rendererSource;
+ expect(source).not.toContain("globalThis.__codexhostCpaLiveVoiceHookV1");
+ expect(install.mock.calls[0]?.[0].overlayHookSource).toBeUndefined();
+ } finally {
+ if (previous === undefined) delete process.env.CODEXHOST_CPA_LIVE_VOICE;
+ else process.env.CODEXHOST_CPA_LIVE_VOICE = previous;
+ }
+ });
+
it("publishes readiness before a pending Renderer install finishes", async () => {
const abort = new AbortController();
const close = vi.fn();
diff --git a/packages/desktop-control/test/renderer-cdp-control-session.test.ts b/packages/desktop-control/test/renderer-cdp-control-session.test.ts
index daab931c8..49c5b59ca 100644
--- a/packages/desktop-control/test/renderer-cdp-control-session.test.ts
+++ b/packages/desktop-control/test/renderer-cdp-control-session.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
createRendererCdpControlSession,
+ selectAvatarOverlayRendererTargets,
selectPrimaryRendererTarget,
} from "../src/renderer-cdp-control-session.js";
import type { CdpTarget } from "../src/cdp-client.js";
@@ -62,6 +63,14 @@ describe("Renderer CDP Control Session", () => {
expect(
selectPrimaryRendererTarget([target("overlay", "app://-/avatar-overlay.html")]),
).toBeNull();
+ expect(
+ selectAvatarOverlayRendererTargets([
+ target("overlay", "app://-/index.html?initialRoute=%2Favatar-overlay"),
+ target("legacy", "app://-/avatar-overlay.html"),
+ first,
+ target("query", "app://-/index.html?window=second"),
+ ]).map((item) => item.id),
+ ).toEqual(["overlay", "legacy"]);
});
it("registers future-document injection before evaluating the current document", async () => {
@@ -95,6 +104,189 @@ describe("Renderer CDP Control Session", () => {
session.close();
});
+ it("injects the hook-only source into the avatar overlay page", async () => {
+ const primary = rendererClient();
+ const overlay = rendererClient();
+ overlay.evaluateSpy.mockResolvedValue(false);
+ const session = await createRendererCdpControlSession({
+ rendererCdpEndpoint: "http://127.0.0.1:43123",
+ rendererSource: "production renderer",
+ overlayHookSource: "overlay hook only",
+ pollIntervalMs: 1,
+ timeoutMs: 100,
+ operations: {
+ listTargets: vi.fn(async () => [
+ target("page-1"),
+ target("overlay", "app://-/index.html?initialRoute=%2Favatar-overlay"),
+ ]),
+ connect: vi.fn(async (webSocketDebuggerUrl: string) =>
+ webSocketDebuggerUrl.includes("overlay") ? overlay : primary,
+ ),
+ installDraftPrewarmPolicy: vi.fn(async () => ({
+ state: "ready" as const,
+ reason: "owned-request-bridge" as const,
+ })),
+ },
+ });
+
+ expect(primary.commands).toEqual(
+ expect.arrayContaining([
+ {
+ method: "Page.addScriptToEvaluateOnNewDocument",
+ params: { source: "production renderer" },
+ },
+ ]),
+ );
+ expect(overlay.commands).toEqual([
+ { method: "Runtime.enable" },
+ { method: "Page.enable" },
+ { method: "Page.addScriptToEvaluateOnNewDocument", params: { source: "overlay hook only" } },
+ {
+ method: "Runtime.evaluate",
+ params: { expression: "overlay hook only", awaitPromise: true },
+ },
+ ]);
+ session.close();
+ });
+
+ it("keeps the primary session when overlay CDP connect fails", async () => {
+ const primary = rendererClient();
+ const session = await createRendererCdpControlSession({
+ rendererCdpEndpoint: "http://127.0.0.1:43123",
+ rendererSource: "production renderer",
+ overlayHookSource: "overlay hook only",
+ pollIntervalMs: 1,
+ timeoutMs: 100,
+ operations: {
+ listTargets: vi.fn(async () => [
+ target("page-1"),
+ target("overlay", "app://-/index.html?initialRoute=%2Favatar-overlay"),
+ ]),
+ connect: vi.fn(async (webSocketDebuggerUrl: string) => {
+ if (webSocketDebuggerUrl.includes("overlay")) {
+ throw new Error("overlay closed between list and connect");
+ }
+ return primary;
+ }),
+ installDraftPrewarmPolicy: vi.fn(async () => ({
+ state: "ready" as const,
+ reason: "owned-request-bridge" as const,
+ })),
+ },
+ });
+
+ expect(session.snapshot.binding).toEqual(readyBinding());
+ expect(primary.close).not.toHaveBeenCalled();
+ session.close();
+ });
+
+ it("re-injects the hook-only overlay bundle when a reload drops the hook", async () => {
+ const primary = rendererClient();
+ const overlay = rendererClient();
+ overlay.evaluateSpy.mockResolvedValue(false);
+ const session = await createRendererCdpControlSession({
+ rendererCdpEndpoint: "http://127.0.0.1:43123",
+ rendererSource: "production renderer",
+ overlayHookSource: "overlay hook only",
+ pollIntervalMs: 1,
+ timeoutMs: 100,
+ operations: {
+ listTargets: vi.fn(async () => [
+ target("page-1"),
+ target("overlay", "app://-/index.html?initialRoute=%2Favatar-overlay"),
+ ]),
+ connect: vi.fn(async (webSocketDebuggerUrl: string) =>
+ webSocketDebuggerUrl.includes("overlay") ? overlay : primary,
+ ),
+ installDraftPrewarmPolicy: vi.fn(async () => ({
+ state: "ready" as const,
+ reason: "owned-request-bridge" as const,
+ })),
+ },
+ });
+
+ overlay.commands.length = 0;
+ await session.ensureInstalled();
+
+ expect(overlay.commands).toEqual([
+ { method: "Runtime.enable" },
+ { method: "Page.enable" },
+ { method: "Page.addScriptToEvaluateOnNewDocument", params: { source: "overlay hook only" } },
+ {
+ method: "Runtime.evaluate",
+ params: { expression: "overlay hook only", awaitPromise: true },
+ },
+ ]);
+ expect(
+ overlay.commands.some(
+ ({ params }) =>
+ params?.source === "production renderer" || params?.expression === "production renderer",
+ ),
+ ).toBe(false);
+ session.close();
+ });
+
+ it.each(["connect", "command", "evaluate"] as const)(
+ "bounds stalled overlay %s operations without blocking other overlays",
+ async (stage) => {
+ const primary = rendererClient();
+ const stalled = rendererClient();
+ const healthy = rendererClient();
+ healthy.evaluateSpy.mockResolvedValue(false);
+ let finishConnect: ((client: typeof stalled) => void) | undefined;
+ const pendingConnect = new Promise((resolve) => {
+ finishConnect = resolve;
+ });
+ if (stage === "command") {
+ stalled.command.mockImplementation(() => new Promise(() => {}));
+ }
+ if (stage === "evaluate") {
+ stalled.evaluateSpy.mockImplementation(() => new Promise(() => {}));
+ }
+ const session = await createRendererCdpControlSession({
+ rendererCdpEndpoint: "http://127.0.0.1:43123",
+ rendererSource: "production renderer",
+ overlayHookSource: "overlay hook only",
+ timeoutMs: 40,
+ operations: {
+ listTargets: async () => [
+ target("page-1"),
+ target("stalled", "app://-/avatar-overlay.html"),
+ target("healthy", "app://-/avatar-overlay.html"),
+ ],
+ connect: async (url) => {
+ if (url.endsWith("/stalled")) return stage === "connect" ? pendingConnect : stalled;
+ return url.endsWith("/healthy") ? healthy : primary;
+ },
+ installDraftPrewarmPolicy: async () => ({
+ state: "ready",
+ reason: "owned-request-bridge",
+ }),
+ },
+ });
+ try {
+ expect(session.snapshot.binding).toEqual(readyBinding());
+ expect(primary.close).not.toHaveBeenCalled();
+ expect(healthy.commands).toContainEqual({
+ method: "Runtime.evaluate",
+ params: { expression: "overlay hook only", awaitPromise: true },
+ });
+ expect(healthy.close).toHaveBeenCalledOnce();
+ if (stage === "connect") {
+ expect(stalled.close).not.toHaveBeenCalled();
+ finishConnect?.(stalled);
+ await vi.waitFor(() => expect(stalled.close).toHaveBeenCalledOnce());
+ expect(stalled.command).not.toHaveBeenCalled();
+ } else {
+ expect(stalled.close).toHaveBeenCalledOnce();
+ }
+ await expect(session.ensureInstalled()).resolves.toMatchObject({ binding: readyBinding() });
+ } finally {
+ session.close();
+ }
+ },
+ );
+
it("reconciles a changed Host before judging the previous Adapter readiness", async () => {
const binding = readyBinding();
const client = rendererClient(binding);
@@ -216,6 +408,44 @@ describe("Renderer CDP Control Session", () => {
expect(client.close).not.toHaveBeenCalled();
});
+ it("keeps the primary session when overlay CDP discovery hangs past its timeout", async () => {
+ const primary = rendererClient();
+ let calls = 0;
+ const session = await createRendererCdpControlSession({
+ rendererCdpEndpoint: "http://127.0.0.1:43123",
+ rendererSource: "production renderer",
+ overlayHookSource: "overlay hook only",
+ pollIntervalMs: 10,
+ timeoutMs: 40,
+ operations: {
+ listTargets: vi.fn((_endpoint, signal) => {
+ calls += 1;
+ if (calls % 2 === 1) return Promise.resolve([target("page-1")]);
+ return new Promise((_resolve, reject) => {
+ const fail = (): void => reject(new Error("Overlay CDP target discovery timed out"));
+ if (signal?.aborted) {
+ fail();
+ return;
+ }
+ signal?.addEventListener("abort", fail, { once: true });
+ });
+ }),
+ connect: vi.fn(async () => primary),
+ installDraftPrewarmPolicy: vi.fn(async () => ({
+ state: "ready" as const,
+ reason: "owned-request-bridge" as const,
+ })),
+ },
+ });
+
+ expect(session.snapshot.binding).toEqual(readyBinding());
+ await expect(session.ensureInstalled()).resolves.toMatchObject({
+ binding: readyBinding(),
+ });
+ expect(primary.close).not.toHaveBeenCalled();
+ session.close();
+ });
+
it("fails closed when the injected Adapter is unsupported", async () => {
const client = rendererClient({
version: 2,
diff --git a/packages/host-runtime/test/app-server-host.projection-1.test.ts b/packages/host-runtime/test/app-server-host.projection-1.test.ts
index a8fed42c8..e3092097b 100644
--- a/packages/host-runtime/test/app-server-host.projection-1.test.ts
+++ b/packages/host-runtime/test/app-server-host.projection-1.test.ts
@@ -1363,7 +1363,7 @@ describe("AppServerHost HarnessAdapter projection", () => {
updateAvailable: true,
installationAvailable: true,
releaseNotes: "Safer updates",
- releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3",
status: null,
error: null,
})),
diff --git a/packages/host-runtime/test/runtime-maintenance.test.ts b/packages/host-runtime/test/runtime-maintenance.test.ts
index 4ba7d75db..b31967964 100644
--- a/packages/host-runtime/test/runtime-maintenance.test.ts
+++ b/packages/host-runtime/test/runtime-maintenance.test.ts
@@ -19,7 +19,7 @@ afterEach(async () => {
async function fixture(remote = false, updaterStartTimeoutMs?: number) {
const root = await mkdtemp(path.join(tmpdir(), "codexhost-maintenance-"));
directories.push(root);
- const pkg = path.join(root, "node_modules/@codexhost/cli-darwin-arm64");
+ const pkg = path.join(root, "node_modules/@karlorz/codexhost-cli-darwin-arm64");
const runtimePath = path.join(pkg, "app/host-runtime.mjs");
await Promise.all(
["app", "libexec", "../cli/bin"].map((dir) => mkdir(path.join(pkg, dir), { recursive: true })),
diff --git a/packages/host-runtime/test/update-coordinator.test.ts b/packages/host-runtime/test/update-coordinator.test.ts
index ca364e627..a26c6b904 100644
--- a/packages/host-runtime/test/update-coordinator.test.ts
+++ b/packages/host-runtime/test/update-coordinator.test.ts
@@ -137,7 +137,7 @@ function release(version = "1.2.3"): CodexhostLatestRelease {
return {
version,
releaseNotes: `Release ${version}`,
- releaseNotesUrl: `https://github.com/BytePioneer-AI/codex-host/releases/tag/v${version}`,
+ releaseNotesUrl: `https://github.com/karlorz/codex-host/releases/tag/v${version}`,
assets: [],
};
}
@@ -268,14 +268,14 @@ describe("Host update coordinator", () => {
fetchLatest: async () => ({
version: "1.2.3",
releaseNotes: "Release 1.2.3",
- releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3",
assets: [
{
name: "codexhost-1.2.3-macos-arm64.dmg",
size: bytes.length,
digest: `sha256:${digest(bytes)}`,
downloadUrl:
- "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-macos-arm64.dmg",
+ "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-macos-arm64.dmg",
},
],
}),
diff --git a/packages/renderer-extension/scripts/build.mjs b/packages/renderer-extension/scripts/build.mjs
index fd00c89d8..d1f3bf35a 100644
--- a/packages/renderer-extension/scripts/build.mjs
+++ b/packages/renderer-extension/scripts/build.mjs
@@ -7,6 +7,7 @@ const packageRoot = path.resolve(import.meta.dirname, "..");
const entries = [
{ entry: "src/index.ts", format: "esm", outfile: "dist/index.js" },
{ entry: "src/production-entry.ts", format: "iife", outfile: "dist/production.js" },
+ { entry: "src/cpa-live-voice-entry.ts", format: "iife", outfile: "dist/cpa-live-voice-hook.js" },
{ entry: "src/probe-entry.ts", format: "iife", outfile: "dist/renderer-binding-probe.js" },
{ entry: "src/audit-entry.ts", format: "iife", outfile: "dist/contract-audit.js" },
{ entry: "src/console-entry.ts", format: "iife", outfile: "dist/console.js" },
diff --git a/packages/renderer-extension/src/cpa-live-voice-entry.ts b/packages/renderer-extension/src/cpa-live-voice-entry.ts
new file mode 100644
index 000000000..22b0b134b
--- /dev/null
+++ b/packages/renderer-extension/src/cpa-live-voice-entry.ts
@@ -0,0 +1,4 @@
+import { installCpaLiveVoiceHook } from "./renderer-cpa-live-voice-hook.js";
+
+// Document-start: do not wait for body. Voice constructs clientOwnsCall during start.
+installCpaLiveVoiceHook();
diff --git a/packages/renderer-extension/src/production-entry.ts b/packages/renderer-extension/src/production-entry.ts
index 2664bbed4..093af42cc 100644
--- a/packages/renderer-extension/src/production-entry.ts
+++ b/packages/renderer-extension/src/production-entry.ts
@@ -1,11 +1,14 @@
+import { installCpaLiveVoiceHook } from "./renderer-cpa-live-voice-hook.js";
import { installRendererBinding } from "./install-renderer-binding.js";
-const install = (): void => {
+installCpaLiveVoiceHook();
+
+const installBinding = (): void => {
installRendererBinding();
};
if (document.documentElement && document.body) {
- install();
+ installBinding();
} else {
- window.addEventListener("DOMContentLoaded", install, { once: true });
+ window.addEventListener("DOMContentLoaded", installBinding, { once: true });
}
diff --git a/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts b/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts
new file mode 100644
index 000000000..5a30e5343
--- /dev/null
+++ b/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts
@@ -0,0 +1,198 @@
+import { cpaLiveVoicePreferenceAllowsInstall } from "./renderer-cpa-live-voice-preference.js";
+
+/**
+ * Runtime equivalent of durable-only `clientOwnsCall` for Codex Desktop
+ * 26.1007.30912. Default on. Does not rewrite ASAR or copy Desktop source.
+ *
+ * Native: hostId === "durable" OR (app-server supports threadRealtimeExistingCall
+ * AND Statsig gate 206132468). The capability is a version comparison, so the
+ * hook forces that gate to false; the gate has no other reader in this build.
+ */
+export const CPA_LIVE_VOICE_HOOK_GLOBAL = "__codexhostCpaLiveVoiceHookV1";
+export const AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION = "26.1007.30912";
+export const AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET = "app-initial-6350d04672d0.js";
+export const THREAD_REALTIME_EXISTING_CALL_GATE = "206132468";
+
+type RecordValue = Record;
+
+export interface CpaLiveVoiceHookConfig {
+ enabled: boolean;
+ auditedDesktopVersion: string;
+ auditedAppInitialAsset: string;
+ desktopVersion?: string;
+}
+
+export type CpaLiveVoiceHookStatus = "disabled" | "pending" | "active" | "unsupported";
+
+export interface CpaLiveVoiceStatsigClient {
+ checkGate(name: string, ...rest: unknown[]): unknown;
+}
+
+export interface CpaLiveVoiceHookHandle {
+ status: CpaLiveVoiceHookStatus;
+ dispose(): void;
+}
+
+export interface InstallCpaLiveVoiceHookOptions {
+ config?: CpaLiveVoiceHookConfig | null;
+ resourceNames?: readonly string[];
+ globalThis?: object;
+ document?: ParentNode;
+}
+
+const originalCheckGate = new WeakMap();
+const DISCOVERY_TIMEOUT_MS = 60_000;
+
+function isRecord(value: unknown): value is RecordValue {
+ return typeof value === "object" && value !== null;
+}
+
+export function readCpaLiveVoiceHookConfig(globalObject: object): CpaLiveVoiceHookConfig | null {
+ const value = (globalObject as Record)[CPA_LIVE_VOICE_HOOK_GLOBAL];
+ if (!isRecord(value) || value.enabled !== true) return null;
+ if (
+ typeof value.auditedDesktopVersion !== "string" ||
+ typeof value.auditedAppInitialAsset !== "string"
+ ) {
+ return null;
+ }
+ return {
+ enabled: true,
+ auditedDesktopVersion: value.auditedDesktopVersion,
+ auditedAppInitialAsset: value.auditedAppInitialAsset,
+ ...(typeof value.desktopVersion === "string" ? { desktopVersion: value.desktopVersion } : {}),
+ };
+}
+
+export function auditedAppInitialAssetPresent(
+ resourceNames: readonly string[],
+ asset: string,
+): boolean {
+ return resourceNames.some((name) => {
+ try {
+ const url = new URL(name, "app://-/");
+ return url.protocol === "app:" && url.hostname === "-" && url.pathname === `/assets/${asset}`;
+ } catch {
+ return false;
+ }
+ });
+}
+
+function resourceNamesFromPerformance(): string[] {
+ if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
+ return [];
+ }
+ return performance
+ .getEntriesByType("resource")
+ .map((entry) => entry.name)
+ .filter((name): name is string => typeof name === "string");
+}
+
+function resourceNamesFromDocument(root: ParentNode | undefined): string[] {
+ const names = resourceNamesFromPerformance();
+ if (!root || !("querySelectorAll" in root)) return names;
+ for (const element of root.querySelectorAll("script[src], link[href]")) {
+ const value = element.getAttribute("src") ?? element.getAttribute("href");
+ if (value) names.push(value);
+ }
+ return names;
+}
+
+/** Statsig JS clients register on `__STATSIG__` (`instances` by SDK key, plus `firstInstance`). */
+export function statsigClients(globalObject: object): CpaLiveVoiceStatsigClient[] {
+ const registry = (globalObject as Record).__STATSIG__;
+ if (!isRecord(registry)) return [];
+ const clients = new Set(
+ isRecord(registry.instances) ? Object.values(registry.instances) : [],
+ );
+ if (registry.firstInstance) clients.add(registry.firstInstance);
+ return [...clients].filter(
+ (client): client is CpaLiveVoiceStatsigClient =>
+ isRecord(client) && typeof client.checkGate === "function",
+ );
+}
+
+export function wrapCheckGate(client: CpaLiveVoiceStatsigClient): boolean {
+ if (originalCheckGate.has(client)) return false;
+ const original = client.checkGate;
+ originalCheckGate.set(client, original);
+ client.checkGate = function (this: unknown, name: string, ...rest: unknown[]) {
+ return name === THREAD_REALTIME_EXISTING_CALL_GATE ? false : original.call(this, name, ...rest);
+ };
+ return true;
+}
+
+function restoreCheckGate(client: CpaLiveVoiceStatsigClient): void {
+ const original = originalCheckGate.get(client);
+ if (!original) return;
+ client.checkGate = original;
+ originalCheckGate.delete(client);
+}
+
+function configAllowsInstall(
+ config: CpaLiveVoiceHookConfig,
+ resourceNames: readonly string[],
+): boolean {
+ if (
+ config.desktopVersion !== undefined &&
+ config.desktopVersion !== config.auditedDesktopVersion
+ ) {
+ return false;
+ }
+ if (config.auditedDesktopVersion !== AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION) return false;
+ if (config.auditedAppInitialAsset !== AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET) return false;
+ return auditedAppInitialAssetPresent(resourceNames, config.auditedAppInitialAsset);
+}
+
+export function installCpaLiveVoiceHook(
+ options: InstallCpaLiveVoiceHookOptions = {},
+): CpaLiveVoiceHookHandle {
+ const globalObject = options.globalThis ?? globalThis;
+ const config =
+ options.config === undefined ? readCpaLiveVoiceHookConfig(globalObject) : options.config;
+ if (!config?.enabled || !cpaLiveVoicePreferenceAllowsInstall(globalObject)) {
+ return { status: "disabled", dispose() {} };
+ }
+ const root = options.document ?? (globalObject as { document?: ParentNode }).document;
+ const wrapped: CpaLiveVoiceStatsigClient[] = [];
+ let disposed = false;
+ let auditedAssetFound = false;
+ const handle: CpaLiveVoiceHookHandle = {
+ status: "pending",
+ dispose() {
+ disposed = true;
+ for (const client of wrapped) restoreCheckGate(client);
+ wrapped.length = 0;
+ },
+ };
+ const attempt = (): boolean => {
+ if (!auditedAssetFound) {
+ const resourceNames = options.resourceNames ?? resourceNamesFromDocument(root);
+ auditedAssetFound = configAllowsInstall(config, resourceNames);
+ if (!auditedAssetFound) return false;
+ }
+ for (const client of statsigClients(globalObject)) {
+ if (wrapCheckGate(client)) wrapped.push(client);
+ }
+ if (wrapped.length === 0) return false;
+ handle.status = "active";
+ return true;
+ };
+ if (attempt()) return handle;
+ if (options.resourceNames && !configAllowsInstall(config, options.resourceNames)) {
+ handle.status = "unsupported";
+ return handle;
+ }
+ // Document-start injection runs before the bundle constructs its Statsig client.
+ const started = Date.now();
+ const tick = (): void => {
+ if (disposed || attempt()) return;
+ if (Date.now() - started > DISCOVERY_TIMEOUT_MS) {
+ handle.status = "unsupported";
+ return;
+ }
+ globalThis.setTimeout(tick, 250);
+ };
+ globalThis.setTimeout(tick, 250);
+ return handle;
+}
diff --git a/packages/renderer-extension/src/renderer-cpa-live-voice-preference.ts b/packages/renderer-extension/src/renderer-cpa-live-voice-preference.ts
new file mode 100644
index 000000000..5c5641693
--- /dev/null
+++ b/packages/renderer-extension/src/renderer-cpa-live-voice-preference.ts
@@ -0,0 +1,30 @@
+export const CPA_LIVE_VOICE_STORAGE_KEY = "codexhost.cpa-live-voice.v1";
+export const CPA_LIVE_VOICE_CHANGE_EVENT = "codexhost:cpa-live-voice-changed";
+
+export function readCpaLiveVoicePreference(owner: Window): boolean {
+ try {
+ return owner.localStorage.getItem(CPA_LIVE_VOICE_STORAGE_KEY) !== "0";
+ } catch {
+ return true;
+ }
+}
+
+export function writeCpaLiveVoicePreference(owner: Window, enabled: boolean): boolean {
+ try {
+ owner.localStorage.setItem(CPA_LIVE_VOICE_STORAGE_KEY, enabled ? "1" : "0");
+ } catch {
+ return false;
+ }
+ owner.dispatchEvent(new Event(CPA_LIVE_VOICE_CHANGE_EVENT));
+ return true;
+}
+
+export function cpaLiveVoicePreferenceAllowsInstall(globalObject: object): boolean {
+ const storage = (globalObject as { localStorage?: Storage }).localStorage;
+ if (!storage) return true;
+ try {
+ return storage.getItem(CPA_LIVE_VOICE_STORAGE_KEY) !== "0";
+ } catch {
+ return true;
+ }
+}
diff --git a/packages/renderer-extension/src/settings/appearance-page.ts b/packages/renderer-extension/src/settings/appearance-page.ts
index 860c6d8df..51607c188 100644
--- a/packages/renderer-extension/src/settings/appearance-page.ts
+++ b/packages/renderer-extension/src/settings/appearance-page.ts
@@ -1,3 +1,9 @@
+import { readCpaLiveVoiceHookConfig } from "../renderer-cpa-live-voice-hook.js";
+import {
+ CPA_LIVE_VOICE_CHANGE_EVENT,
+ readCpaLiveVoicePreference,
+ writeCpaLiveVoicePreference,
+} from "../renderer-cpa-live-voice-preference.js";
import {
REASONING_SOFT_WRAP_CHANGE_EVENT,
readReasoningTranscriptSoftWrap,
@@ -35,6 +41,32 @@ export function createAppearanceSettingsPage(
description.textContent = messages.appearanceDescription;
const { group, card } = createPreferenceGroup(document, messages.appearanceGroup);
+ const liveVoiceId = preferenceId("cpa-live-voice");
+ const liveVoiceRow = createPreferenceItem(document, {
+ title: messages.cpaLiveVoiceTitle,
+ description: messages.cpaLiveVoiceDescription,
+ controlId: liveVoiceId,
+ help: { label: messages.cpaLiveVoiceHelpLabel, lines: messages.cpaLiveVoiceHelp },
+ });
+ const liveVoice = createPreferenceSwitch(document, liveVoiceId, liveVoiceRow.description.id);
+ liveVoice.checked = readCpaLiveVoicePreference(ownerWindow);
+ liveVoice.addEventListener("change", () => {
+ writeCpaLiveVoicePreference(ownerWindow, liveVoice.checked);
+ });
+ const syncLiveVoice = (): void => {
+ liveVoice.checked = readCpaLiveVoicePreference(ownerWindow);
+ };
+ ownerWindow.addEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, syncLiveVoice);
+ liveVoiceRow.item.append(liveVoice);
+ if (!readCpaLiveVoiceHookConfig(ownerWindow)) {
+ const warning = document.createElement("div");
+ warning.className = "text-xs leading-[18px] text-settings-warning";
+ warning.setAttribute("role", "status");
+ warning.textContent = messages.cpaLiveVoiceRestartRequired;
+ liveVoiceRow.description.after(warning);
+ }
+ card.append(liveVoiceRow.item);
+
const softWrapId = preferenceId("reasoning-soft-wrap");
const row = createPreferenceItem(document, {
title: messages.reasoningSoftWrapTitle,
@@ -60,6 +92,7 @@ export function createAppearanceSettingsPage(
getLoadedSessionsClient,
);
return () => {
+ ownerWindow.removeEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, syncLiveVoice);
ownerWindow.removeEventListener(REASONING_SOFT_WRAP_CHANGE_EVENT, sync);
disposeIdleRelease();
};
diff --git a/packages/renderer-extension/src/settings/connections-page.ts b/packages/renderer-extension/src/settings/connections-page.ts
index c95a0ef85..01e500d55 100644
--- a/packages/renderer-extension/src/settings/connections-page.ts
+++ b/packages/renderer-extension/src/settings/connections-page.ts
@@ -23,8 +23,7 @@ import { harnessInstallStore } from "./harness-install-store.js";
import { createHarnessVersionPanel } from "./harness-version-panel.js";
import type { RendererSettingsMessages } from "./localization.js";
-export const CODEXHOST_GITHUB_ISSUES_NEW_URL =
- "https://github.com/BytePioneer-AI/codex-host/issues/new";
+export const CODEXHOST_GITHUB_ISSUES_NEW_URL = "https://github.com/karlorz/codex-host/issues/new";
export interface RendererConnectionAgentSnapshot {
readonly agent: ExternalRendererAgent;
diff --git a/packages/renderer-extension/src/settings/localization.ts b/packages/renderer-extension/src/settings/localization.ts
index f4eda0d85..88a89709c 100644
--- a/packages/renderer-extension/src/settings/localization.ts
+++ b/packages/renderer-extension/src/settings/localization.ts
@@ -38,6 +38,11 @@ export interface RendererSettingsMessages {
readonly otherSection: string;
readonly appearanceDescription: string;
readonly appearanceGroup: string;
+ readonly cpaLiveVoiceTitle: string;
+ readonly cpaLiveVoiceDescription: string;
+ readonly cpaLiveVoiceHelpLabel: string;
+ readonly cpaLiveVoiceHelp: readonly string[];
+ readonly cpaLiveVoiceRestartRequired: string;
readonly loadedSessions: {
title: string;
description: string;
@@ -290,6 +295,17 @@ const ENGLISH_MESSAGES: RendererSettingsMessages = Object.freeze({
otherSection: "Other",
appearanceDescription: "Conversation display and local resource management.",
appearanceGroup: "Appearance",
+ cpaLiveVoiceTitle: "Override live voice",
+ cpaLiveVoiceDescription: "On by default. Host creates the realtime call instead of ChatGPT.",
+ cpaLiveVoiceHelpLabel: "About overriding live voice",
+ cpaLiveVoiceHelp: Object.freeze([
+ "Overrides Desktop's existing-call path on audited 26.1007.30912 so a local host can create the WebRTC call.",
+ "This switch applies on the next voice session. No relaunch.",
+ "Also set both keys in ~/.codex/config.toml and relaunch Codex: experimental_realtime_ws_base_url and experimental_realtime_webrtc_call_base_url, each pointing at your realtime host /v1.",
+ "CODEXHOST_CPA_LIVE_VOICE=0 is not live. Relaunch Codex after changing that environment variable.",
+ ]),
+ cpaLiveVoiceRestartRequired:
+ "This override is not injected. Unset CODEXHOST_CPA_LIVE_VOICE=0 and relaunch Codex. The switch cannot take effect until then.",
loadedSessions: {
title: "Loaded sessions",
description:
@@ -597,6 +613,17 @@ const CHINESE_MESSAGES: RendererSettingsMessages = Object.freeze({
otherSection: "其他",
appearanceDescription: "会话显示与本地资源管理。",
appearanceGroup: "外观",
+ cpaLiveVoiceTitle: "改写实时语音",
+ cpaLiveVoiceDescription: "默认开启。由 Host 创建 realtime 通话,而不是 ChatGPT。",
+ cpaLiveVoiceHelpLabel: "改写实时语音说明",
+ cpaLiveVoiceHelp: Object.freeze([
+ "在已审计的 Desktop 26.1007.30912 上覆盖 existing-call 路径,让本地 Host 创建 WebRTC 通话。",
+ "此开关在下一次语音会话生效,无需重新启动。",
+ "还需要在 ~/.codex/config.toml 设置 experimental_realtime_ws_base_url 与 experimental_realtime_webrtc_call_base_url,都指向你的 realtime 主机 /v1,然后重新启动 Codex。",
+ "CODEXHOST_CPA_LIVE_VOICE=0 不会即时生效。修改该环境变量后必须重新启动 Codex。",
+ ]),
+ cpaLiveVoiceRestartRequired:
+ "当前未注入此改写。请取消 CODEXHOST_CPA_LIVE_VOICE=0 并重新启动 Codex,本开关才会生效。",
loadedSessions: {
title: "已加载会话",
description:
diff --git a/packages/renderer-extension/src/settings/pages.ts b/packages/renderer-extension/src/settings/pages.ts
index a83aa236f..a054f489f 100644
--- a/packages/renderer-extension/src/settings/pages.ts
+++ b/packages/renderer-extension/src/settings/pages.ts
@@ -49,9 +49,9 @@ import {
runBoundedRendererUpdateRequest,
} from "./update-request.js";
-export const CODEXHOST_GITHUB_REPOSITORY_URL = "https://github.com/BytePioneer-AI/codex-host";
+export const CODEXHOST_GITHUB_REPOSITORY_URL = "https://github.com/karlorz/codex-host";
export const CODEXHOST_RELEASES_LATEST_URL = `${CODEXHOST_GITHUB_REPOSITORY_URL}/releases/latest`;
-export const CODEXHOST_NPM_MANUAL_UPDATE_COMMAND = "npm install -g @codexhost/cli@latest";
+export const CODEXHOST_NPM_MANUAL_UPDATE_COMMAND = "npm install -g @karlorz/codexhost-cli@latest";
interface RendererUserAgentData {
readonly platform?: string;
@@ -75,7 +75,7 @@ function windowsInstallerDownloadUrl(window: Window | null | undefined, version:
const hints = navigator ? rendererUserAgentData(navigator) : undefined;
const identity = `${hints?.architecture ?? ""} ${hints?.platform ?? ""} ${navigator?.platform ?? ""} ${navigator?.userAgent ?? ""}`;
const architecture = /arm64|aarch64|\barm\b/iu.test(identity) ? "arm64" : "x64";
- return `https://github.com/BytePioneer-AI/codex-host/releases/download/v${version}/codexhost-${version}-windows-${architecture}.exe`;
+ return `${CODEXHOST_GITHUB_REPOSITORY_URL}/releases/download/v${version}/codexhost-${version}-windows-${architecture}.exe`;
}
export const DEFAULT_RENDERER_SETTINGS_PAGE_IDS = [
diff --git a/packages/renderer-extension/test/renderer-cpa-live-voice-hook.test.ts b/packages/renderer-extension/test/renderer-cpa-live-voice-hook.test.ts
new file mode 100644
index 000000000..c4af69792
--- /dev/null
+++ b/packages/renderer-extension/test/renderer-cpa-live-voice-hook.test.ts
@@ -0,0 +1,198 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { CPA_LIVE_VOICE_STORAGE_KEY } from "../src/renderer-cpa-live-voice-preference.js";
+import {
+ AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
+ AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION,
+ THREAD_REALTIME_EXISTING_CALL_GATE,
+ auditedAppInitialAssetPresent,
+ installCpaLiveVoiceHook,
+ readCpaLiveVoiceHookConfig,
+ statsigClients,
+ wrapCheckGate,
+} from "../src/renderer-cpa-live-voice-hook.js";
+
+const AUDITED_ASSET = [`app://-/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`];
+
+const enabledConfig = {
+ enabled: true as const,
+ auditedDesktopVersion: AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION,
+ auditedAppInitialAsset: AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
+};
+
+function statsigClient() {
+ return {
+ checkGate(name: string): boolean {
+ return name.length > 0;
+ },
+ };
+}
+
+function statsigGlobal(client = statsigClient()) {
+ return {
+ client,
+ globalObject: { __STATSIG__: { instances: { key: client }, firstInstance: client } },
+ };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("CPA live-voice hook gating", () => {
+ it.each([
+ `app://-/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}.map`,
+ `app://-/assets/other.js?asset=${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`,
+ `https://example.test/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`,
+ `app://-/other/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`,
+ ])("rejects an asset-name substring in %s", (resourceName) => {
+ expect(
+ auditedAppInitialAssetPresent([resourceName], AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET),
+ ).toBe(false);
+ });
+
+ it("accepts the exact audited asset as a relative script URL", () => {
+ expect(
+ auditedAppInitialAssetPresent(
+ [`/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}`],
+ AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
+ ),
+ ).toBe(true);
+ });
+
+ it("rejects config that substitutes a different app-initial asset", () => {
+ const { client, globalObject } = statsigGlobal();
+ const hook = installCpaLiveVoiceHook({
+ config: { ...enabledConfig, auditedAppInitialAsset: "other.js" },
+ resourceNames: ["app://-/assets/other.js"],
+ globalThis: globalObject,
+ });
+ expect(hook.status).toBe("unsupported");
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true);
+ });
+
+ it("stays off when Settings stores the preference as 0", () => {
+ const { client, globalObject } = statsigGlobal();
+ Object.assign(globalObject, {
+ localStorage: {
+ getItem: (key: string) => (key === CPA_LIVE_VOICE_STORAGE_KEY ? "0" : null),
+ },
+ });
+ const hook = installCpaLiveVoiceHook({
+ config: enabledConfig,
+ resourceNames: AUDITED_ASSET,
+ globalThis: globalObject,
+ });
+ expect(hook.status).toBe("disabled");
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true);
+ });
+
+ it("stays off without an explicit enable config", () => {
+ expect(readCpaLiveVoiceHookConfig({})).toBeNull();
+ const { client, globalObject } = statsigGlobal();
+ const hook = installCpaLiveVoiceHook({
+ config: { ...enabledConfig, enabled: false },
+ resourceNames: AUDITED_ASSET,
+ globalThis: globalObject,
+ });
+ expect(hook.status).toBe("disabled");
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true);
+ });
+
+ it("fails closed when the audited app-initial asset is absent", () => {
+ expect(
+ auditedAppInitialAssetPresent(
+ ["app://-/assets/other.js"],
+ enabledConfig.auditedAppInitialAsset,
+ ),
+ ).toBe(false);
+ const { client, globalObject } = statsigGlobal();
+ const hook = installCpaLiveVoiceHook({
+ config: enabledConfig,
+ resourceNames: ["app://-/assets/app-initial-deadbeef.js"],
+ globalThis: globalObject,
+ });
+ expect(hook.status).toBe("unsupported");
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true);
+ });
+
+ it("fails closed when an injected Desktop version is not the audited build", () => {
+ const { client, globalObject } = statsigGlobal();
+ const hook = installCpaLiveVoiceHook({
+ config: { ...enabledConfig, desktopVersion: "26.9999.0" },
+ resourceNames: AUDITED_ASSET,
+ globalThis: globalObject,
+ });
+ expect(hook.status).toBe("unsupported");
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true);
+ });
+});
+
+describe("CPA live-voice existing-call gate override", () => {
+ it("reads each registered Statsig client once", () => {
+ const { client, globalObject } = statsigGlobal();
+ expect(statsigClients(globalObject)).toEqual([client]);
+ expect(statsigClients({})).toEqual([]);
+ });
+
+ it("forces only the existing-call gate off and passes other gates through", () => {
+ const client = statsigClient();
+ expect(wrapCheckGate(client)).toBe(true);
+ expect(wrapCheckGate(client)).toBe(false);
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(false);
+ expect(client.checkGate("other_gate")).toBe(true);
+ });
+
+ it("activates on the audited asset and restores on dispose", () => {
+ const { client, globalObject } = statsigGlobal();
+ const hook = installCpaLiveVoiceHook({
+ config: enabledConfig,
+ resourceNames: AUDITED_ASSET,
+ globalThis: globalObject,
+ });
+ expect(hook.status).toBe("active");
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(false);
+ hook.dispose();
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(true);
+ });
+
+ it("wraps a Statsig client registered after document-start injection", () => {
+ vi.useFakeTimers();
+ const globalObject: Record = {};
+ const hook = installCpaLiveVoiceHook({
+ config: enabledConfig,
+ resourceNames: AUDITED_ASSET,
+ globalThis: globalObject,
+ });
+ expect(hook.status).toBe("pending");
+ const client = statsigClient();
+ globalObject.__STATSIG__ = { firstInstance: client };
+ vi.advanceTimersByTime(250);
+ expect(hook.status).toBe("active");
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(false);
+ hook.dispose();
+ });
+
+ it("stops rescanning assets while waiting for Statsig after an audited match", () => {
+ vi.useFakeTimers();
+ const globalObject: Record = {};
+ const querySelectorAll = vi.fn(() => [
+ { getAttribute: () => `/assets/${AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET}` },
+ ]);
+ const hook = installCpaLiveVoiceHook({
+ config: enabledConfig,
+ globalThis: globalObject,
+ document: { querySelectorAll } as unknown as ParentNode,
+ });
+ vi.advanceTimersByTime(1_000);
+ expect(hook.status).toBe("pending");
+ expect(querySelectorAll).toHaveBeenCalledOnce();
+ const client = statsigClient();
+ globalObject.__STATSIG__ = { firstInstance: client };
+ vi.advanceTimersByTime(250);
+ expect(hook.status).toBe("active");
+ expect(client.checkGate(THREAD_REALTIME_EXISTING_CALL_GATE)).toBe(false);
+ expect(querySelectorAll).toHaveBeenCalledOnce();
+ hook.dispose();
+ });
+});
diff --git a/packages/renderer-extension/test/renderer-cpa-live-voice-preference.test.ts b/packages/renderer-extension/test/renderer-cpa-live-voice-preference.test.ts
new file mode 100644
index 000000000..f48344468
--- /dev/null
+++ b/packages/renderer-extension/test/renderer-cpa-live-voice-preference.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ CPA_LIVE_VOICE_CHANGE_EVENT,
+ CPA_LIVE_VOICE_STORAGE_KEY,
+ cpaLiveVoicePreferenceAllowsInstall,
+ readCpaLiveVoicePreference,
+ writeCpaLiveVoicePreference,
+} from "../src/renderer-cpa-live-voice-preference.js";
+
+function fixture(initial?: string) {
+ const values = new Map();
+ if (initial !== undefined) values.set(CPA_LIVE_VOICE_STORAGE_KEY, initial);
+ const storage = {
+ getItem: vi.fn((key: string) => values.get(key) ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ values.set(key, value);
+ }),
+ };
+ const owner = Object.assign(new EventTarget(), { localStorage: storage }) as unknown as Window;
+ return { owner, values, storage };
+}
+
+describe("CPA live-voice Settings preference", () => {
+ it("defaults on, persists off as 0, and allows install when storage is missing", () => {
+ const unset = fixture();
+ expect(readCpaLiveVoicePreference(unset.owner)).toBe(true);
+ expect(cpaLiveVoicePreferenceAllowsInstall({})).toBe(true);
+ expect(cpaLiveVoicePreferenceAllowsInstall(unset.owner)).toBe(true);
+
+ const events: string[] = [];
+ unset.owner.addEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, () => events.push("changed"));
+ expect(writeCpaLiveVoicePreference(unset.owner, false)).toBe(true);
+ expect(unset.values.get(CPA_LIVE_VOICE_STORAGE_KEY)).toBe("0");
+ expect(readCpaLiveVoicePreference(unset.owner)).toBe(false);
+ expect(cpaLiveVoicePreferenceAllowsInstall(unset.owner)).toBe(false);
+ expect(events).toEqual(["changed"]);
+
+ expect(writeCpaLiveVoicePreference(unset.owner, true)).toBe(true);
+ expect(unset.values.get(CPA_LIVE_VOICE_STORAGE_KEY)).toBe("1");
+ expect(readCpaLiveVoicePreference(unset.owner)).toBe(true);
+ });
+
+ it("stays on when localStorage throws", () => {
+ const owner = Object.assign(new EventTarget(), {
+ localStorage: {
+ getItem: () => {
+ throw new Error("blocked");
+ },
+ setItem: () => {
+ throw new Error("blocked");
+ },
+ },
+ }) as unknown as Window;
+ expect(readCpaLiveVoicePreference(owner)).toBe(true);
+ expect(writeCpaLiveVoicePreference(owner, false)).toBe(false);
+ expect(cpaLiveVoicePreferenceAllowsInstall(owner)).toBe(true);
+ });
+});
diff --git a/packages/renderer-extension/test/renderer-model-client.test.ts b/packages/renderer-extension/test/renderer-model-client.test.ts
index 538472261..9e89e1e33 100644
--- a/packages/renderer-extension/test/renderer-model-client.test.ts
+++ b/packages/renderer-extension/test/renderer-model-client.test.ts
@@ -378,7 +378,7 @@ describe("Renderer fixed Model request client", () => {
updateAvailable: true,
installationAvailable: true,
releaseNotes: "Safer updates",
- releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3",
status: null,
error: null,
})
@@ -875,7 +875,7 @@ describe("Renderer fixed Model request client", () => {
updateAvailable: true,
installationAvailable: true,
releaseNotes: "Safer updates",
- releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3",
status: null,
error: null,
artifactUrl: "https://example.com/update.exe",
diff --git a/packages/renderer-extension/test/settings/localization.test.ts b/packages/renderer-extension/test/settings/localization.test.ts
index fda26a971..3ad04d3b3 100644
--- a/packages/renderer-extension/test/settings/localization.test.ts
+++ b/packages/renderer-extension/test/settings/localization.test.ts
@@ -33,6 +33,19 @@ describe("Renderer settings localization", () => {
expect(english.title).toBe("Settings");
expect(chinese.title).toBe("设置");
+ expect(english.cpaLiveVoiceTitle).toBe("Override live voice");
+ expect(chinese.cpaLiveVoiceTitle).toBe("改写实时语音");
+ expect(english.cpaLiveVoiceTitle.toLowerCase()).not.toContain("cpa");
+ expect(chinese.cpaLiveVoiceTitle.toLowerCase()).not.toContain("cpa");
+ expect(english.cpaLiveVoiceDescription).toContain("On by default");
+ expect(chinese.cpaLiveVoiceDescription).toContain("默认开启");
+ expect(english.cpaLiveVoiceHelp.join(" ")).toContain("experimental_realtime_ws_base_url");
+ expect(chinese.cpaLiveVoiceHelp.join(" ")).toContain(
+ "experimental_realtime_webrtc_call_base_url",
+ );
+ expect(english.cpaLiveVoiceHelp.join(" ")).toContain("is not live");
+ expect(english.cpaLiveVoiceRestartRequired).toContain("relaunch Codex");
+ expect(chinese.cpaLiveVoiceRestartRequired).toContain("重新启动 Codex");
expect(chinese.openSettings).toBe("打开 codexhost 设置");
expect(chinese.starOnGitHub).toBe("Star 支持");
expect(english.starOnGitHub).toBe("Star to support");
diff --git a/packages/renderer-extension/test/settings/pages.test.ts b/packages/renderer-extension/test/settings/pages.test.ts
index e75710cf5..1608660f0 100644
--- a/packages/renderer-extension/test/settings/pages.test.ts
+++ b/packages/renderer-extension/test/settings/pages.test.ts
@@ -221,7 +221,7 @@ function updateCheck(status: UpdateStatus | null = null): UpdateCheckResult {
updateAvailable: true,
installationAvailable: true,
releaseNotes: "Safer updates",
- releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3",
status,
error: null,
};
@@ -1759,7 +1759,7 @@ describe("Renderer Connections page", () => {
expect(report).toContain("stderr:\ncheck ~/.pi/agent/settings.json");
const issueLink = descendants(content).find(
({ tagName, href }) =>
- tagName === "a" && href === "https://github.com/BytePioneer-AI/codex-host/issues/new",
+ tagName === "a" && href === "https://github.com/karlorz/codex-host/issues/new",
);
expect(issueLink).toBeDefined();
const copyButton = descendants(
@@ -2188,9 +2188,7 @@ describe("Renderer Updates page", () => {
expect(releaseLink.rel).toBe("noopener noreferrer");
await vi.waitFor(() => {
- expect(releaseLink.href).toBe(
- "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
- );
+ expect(releaseLink.href).toBe("https://github.com/karlorz/codex-host/releases/tag/v1.2.3");
});
const panel = elementWithClass(content, "settings-update-panel");
@@ -2202,9 +2200,7 @@ describe("Renderer Updates page", () => {
expect(panel.dataset.updateState).toBe("failed");
});
expect(descendants(content)).toContain(releaseLink);
- expect(releaseLink.href).toBe(
- "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
- );
+ expect(releaseLink.href).toBe("https://github.com/karlorz/codex-host/releases/tag/v1.2.3");
cleanup?.();
scope.dispose();
@@ -2293,13 +2289,13 @@ describe("Renderer Updates page", () => {
).toBeUndefined();
expect(client.startUpdate).not.toHaveBeenCalled();
if (installation === "npm") {
- expect(visibleText(content)).toContain("npm install -g @codexhost/cli@latest");
+ expect(visibleText(content)).toContain("npm install -g @karlorz/codexhost-cli@latest");
} else {
const link = descendants(content).find(
({ tagName, href }) =>
tagName === "a" &&
href ===
- "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
+ "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
);
expect(link).toMatchObject({ target: "_blank", rel: "noopener noreferrer" });
}
@@ -2331,12 +2327,11 @@ describe("Renderer Updates page", () => {
expect(visibleText(content)).toContain("codexhost 是一个开源项目");
expect(visibleText(content)).toContain("请给我们一个 Star");
const repository = descendants(content).find(
- ({ tagName, href }) =>
- tagName === "a" && href === "https://github.com/BytePioneer-AI/codex-host",
+ ({ tagName, href }) => tagName === "a" && href === "https://github.com/karlorz/codex-host",
);
expect(repository).toMatchObject({ target: "_blank", rel: "noopener noreferrer" });
expect(visibleNotesText(repository as FakeElement)).toContain(
- "https://github.com/BytePioneer-AI/codex-host",
+ "https://github.com/karlorz/codex-host",
);
cleanup?.();
@@ -2446,7 +2441,7 @@ describe("Renderer Updates page", () => {
expect(visibleText(starBanner)).toContain("如果 CodexHost 帮到了你,请在 GitHub 点个 Star");
const starLink = descendants(starBanner).find(({ tagName }) => tagName === "a");
expect(starLink).toMatchObject({
- href: "https://github.com/BytePioneer-AI/codex-host",
+ href: "https://github.com/karlorz/codex-host",
target: "_blank",
rel: "noopener noreferrer",
});
diff --git a/packages/renderer-extension/test/settings/release-notes.test.ts b/packages/renderer-extension/test/settings/release-notes.test.ts
index b3454e850..293229478 100644
--- a/packages/renderer-extension/test/settings/release-notes.test.ts
+++ b/packages/renderer-extension/test/settings/release-notes.test.ts
@@ -76,7 +76,7 @@ describe("Release notes Markdown", () => {
"### npm",
"",
"```bash",
- "npm install -g @codexhost/cli",
+ "npm install -g @karlorz/codexhost-cli",
"codexhost",
"```",
].join("\n"),
@@ -94,13 +94,13 @@ describe("Release notes Markdown", () => {
expect(visibleText(root)).toContain("本次发布");
expect(visibleText(root)).not.toContain("##");
expect(visibleText(root)).not.toContain("- 新增");
- expect(visibleText(root)).toContain("npm install -g @codexhost/cli");
+ expect(visibleText(root)).toContain("npm install -g @karlorz/codexhost-cli");
const list = descendants(root).find((element) => element.tagName === "ul");
expect(list?.children).toHaveLength(2);
const code = descendants(root).find((element) => element.tagName === "code");
expect(code?.className).toBe("language-bash");
- expect(code?.textContent).toBe("npm install -g @codexhost/cli\ncodexhost");
+ expect(code?.textContent).toBe("npm install -g @karlorz/codexhost-cli\ncodexhost");
});
it("sets the translation line of a bilingual paragraph apart", () => {
@@ -132,7 +132,7 @@ describe("Release notes Markdown", () => {
"Use `codexhost` and **restart** after install.",
"",
"1. Download the package",
- "2. Open [Releases](https://github.com/BytePioneer-AI/codex-host/releases)",
+ "2. Open [Releases](https://github.com/karlorz/codex-host/releases)",
].join("\n"),
);
@@ -143,9 +143,7 @@ describe("Release notes Markdown", () => {
expect(strong?.textContent).toBe("restart");
const link = descendants(root).find((element) => element.tagName === "a");
expect(link?.textContent).toBe("Releases");
- expect(link?.attributes.get("href")).toBe(
- "https://github.com/BytePioneer-AI/codex-host/releases",
- );
+ expect(link?.attributes.get("href")).toBe("https://github.com/karlorz/codex-host/releases");
expect(link?.attributes.get("target")).toBe("_blank");
expect(link?.attributes.get("rel")).toBe("noopener noreferrer");
});
diff --git a/packages/shared-contracts/src/updates.ts b/packages/shared-contracts/src/updates.ts
index cc4359bcc..f0e1a3ea2 100644
--- a/packages/shared-contracts/src/updates.ts
+++ b/packages/shared-contracts/src/updates.ts
@@ -46,7 +46,7 @@ const githubReleaseNotesUrlSchema = z
.string()
.max(300)
.regex(
- /^https:\/\/github\.com\/BytePioneer-AI\/codex-host\/releases\/tag\/v[0-9A-Za-z.+-]+$/u,
+ /^https:\/\/github\.com\/karlorz\/codex-host\/releases\/tag\/v[0-9A-Za-z.+-]+$/u,
"release notes URL must identify a codexhost GitHub Release",
);
diff --git a/packages/shared-contracts/test/updates.test.ts b/packages/shared-contracts/test/updates.test.ts
index c191154d5..f91c8b875 100644
--- a/packages/shared-contracts/test/updates.test.ts
+++ b/packages/shared-contracts/test/updates.test.ts
@@ -26,7 +26,7 @@ describe("update runtime contracts", () => {
updateAvailable: true,
installationAvailable: true,
releaseNotes: "## Changes\n\n- Safer updates",
- releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3",
status: null,
error: null,
}),
@@ -111,7 +111,7 @@ describe("update runtime contracts", () => {
updateAvailable: true,
installationAvailable: true,
releaseNotes: "x".repeat(20_001),
- releaseNotesUrl: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3",
status: null,
error: null,
}).success,
diff --git a/packages/update-manager/src/github-cli-release.ts b/packages/update-manager/src/github-cli-release.ts
index 2718c2a98..d6e98c1c1 100644
--- a/packages/update-manager/src/github-cli-release.ts
+++ b/packages/update-manager/src/github-cli-release.ts
@@ -1,9 +1,13 @@
import { execFile } from "node:child_process";
import path from "node:path";
-import { parseLatestGitHubRelease, type CodexhostLatestRelease } from "./github-release.js";
+import {
+ GITHUB_REPOSITORY,
+ parseLatestGitHubRelease,
+ type CodexhostLatestRelease,
+} from "./github-release.js";
-const GITHUB_LATEST_RELEASE_ENDPOINT = "repos/BytePioneer-AI/codex-host/releases/latest";
+const GITHUB_LATEST_RELEASE_ENDPOINT = `repos/${GITHUB_REPOSITORY}/releases/latest`;
const MAX_RELEASE_RESPONSE_BYTES = 1024 * 1024;
const GITHUB_CLI_TIMEOUT_MS = 5_000;
diff --git a/packages/update-manager/src/github-release.ts b/packages/update-manager/src/github-release.ts
index a8611cb42..326b744f9 100644
--- a/packages/update-manager/src/github-release.ts
+++ b/packages/update-manager/src/github-release.ts
@@ -1,13 +1,15 @@
import type { ArtifactSource } from "./artifact.js";
import { requireSemanticVersion } from "./status.js";
-export const CODEXHOST_LATEST_RELEASE_URL =
- "https://api.github.com/repos/BytePioneer-AI/codex-host/releases/latest";
+export const GITHUB_REPOSITORY = "karlorz/codex-host";
+export const CODEXHOST_LATEST_RELEASE_URL = `https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest`;
const SHA256_DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/u;
-const RELEASE_NOTES_URL_PATTERN =
- /^https:\/\/github\.com\/BytePioneer-AI\/codex-host\/releases\/tag\/(v[0-9A-Za-z.+-]+)$/u;
-const DOWNLOAD_URL_PREFIX = "https://github.com/BytePioneer-AI/codex-host/releases/download/";
+const RELEASE_NOTES_URL_PATTERN = new RegExp(
+ `^https://github\\.com/${GITHUB_REPOSITORY.replaceAll("/", "\\/")}/releases/tag/(v[0-9A-Za-z.+-]+)$`,
+ "u",
+);
+const DOWNLOAD_URL_PREFIX = `https://github.com/${GITHUB_REPOSITORY}/releases/download/`;
export type InstallerReleaseTarget = "macos-arm64" | "macos-x64" | "windows-x64" | "windows-arm64";
export type ReleaseTarget = InstallerReleaseTarget | "linux-x64" | "linux-arm64";
diff --git a/packages/update-manager/test/github-release.test.ts b/packages/update-manager/test/github-release.test.ts
index df760c3e7..db409b79a 100644
--- a/packages/update-manager/test/github-release.test.ts
+++ b/packages/update-manager/test/github-release.test.ts
@@ -14,7 +14,7 @@ import {
function release(overrides: Record = {}) {
return {
tag_name: "v1.2.3",
- html_url: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ html_url: "https://github.com/karlorz/codex-host/releases/tag/v1.2.3",
draft: false,
prerelease: false,
body: "## Changes\n\n- Safer updates",
@@ -24,7 +24,7 @@ function release(overrides: Record = {}) {
size: 42,
digest: `sha256:${"ab".repeat(32)}`,
browser_download_url:
- "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
+ "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
uploader: { login: "github-actions" },
},
],
@@ -41,7 +41,7 @@ describe("GitHub Release update discovery", () => {
expect(selectInstallerReleaseArtifact(parsed, "windows-x64")).toEqual({
name: "codexhost-1.2.3-windows-x64.exe",
source: {
- url: "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
+ url: "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
sha256: "ab".repeat(32),
size: 42,
},
@@ -56,7 +56,7 @@ describe("GitHub Release update discovery", () => {
expect(() =>
parseLatestGitHubRelease(
release({
- html_url: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v9.9.9",
+ html_url: "https://github.com/karlorz/codex-host/releases/tag/v9.9.9",
}),
),
).toThrow("does not match");
@@ -67,7 +67,7 @@ describe("GitHub Release update discovery", () => {
name: "codexhost-1.2.3-windows-x64.exe",
size: 42,
browser_download_url:
- "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
+ "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
},
],
}),
@@ -135,7 +135,7 @@ describe("GitHub Release update discovery", () => {
"Accept: application/vnd.github+json",
"--header",
"X-GitHub-Api-Version: 2022-11-28",
- "repos/BytePioneer-AI/codex-host/releases/latest",
+ "repos/karlorz/codex-host/releases/latest",
],
{ environment: { PATH: "/usr/bin:/bin" } },
);
diff --git a/scripts/release/macos/package.sh b/scripts/release/macos/package.sh
index 93260be10..f9cdd7b92 100755
--- a/scripts/release/macos/package.sh
+++ b/scripts/release/macos/package.sh
@@ -31,6 +31,7 @@ for relative in \
app/desktop-controller.mjs \
app/host-runtime.mjs \
app/renderer-extension.js \
+ app/cpa-live-voice-hook.js \
licenses/Node.js-LICENSE.txt \
licenses/Anthropic-SDK-LICENSE.txt \
licenses/Claude-Agent-SDK-LICENSE.md \
diff --git a/scripts/release/prepare-npm-meta.mjs b/scripts/release/prepare-npm-meta.mjs
index 3a7c83030..3b1087f44 100644
--- a/scripts/release/prepare-npm-meta.mjs
+++ b/scripts/release/prepare-npm-meta.mjs
@@ -7,6 +7,7 @@ import {
NPM_PACKAGE_NAME,
NPM_PLATFORM_PACKAGE_NAMES,
createNpmBinLauncherSource,
+ npmPackageRepositoryFields,
packNpmPackage,
resolveNpmPackageVersion,
} from "./prepare-npm.mjs";
@@ -30,12 +31,7 @@ export function createNpmMetaPackageManifest({ version }) {
Object.values(NPM_PLATFORM_PACKAGE_NAMES).map((packageName) => [packageName, version]),
),
keywords: ["codex", "codexhost", "pi", "claude-code", "agent", "harness"],
- repository: {
- type: "git",
- url: "git+https://github.com/BytePioneer-AI/codex-host.git",
- },
- bugs: { url: "https://github.com/BytePioneer-AI/codex-host/issues" },
- homepage: "https://github.com/BytePioneer-AI/codex-host#readme",
+ ...npmPackageRepositoryFields(),
publishConfig: { access: "public" },
};
}
diff --git a/scripts/release/prepare-npm.mjs b/scripts/release/prepare-npm.mjs
index d7583c8a1..ad9c85500 100644
--- a/scripts/release/prepare-npm.mjs
+++ b/scripts/release/prepare-npm.mjs
@@ -23,15 +23,40 @@ import { hostReleaseTarget, npmReleaseUsage, releaseTargetForHost } from "./targ
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
-export const NPM_PACKAGE_NAME = "@codexhost/cli";
+export const NPM_GITHUB_REPOSITORY = "karlorz/codex-host";
+export const NPM_PACKAGE_NAME = "@karlorz/codexhost-cli";
export const NPM_PLATFORM_PACKAGE_NAMES = Object.freeze({
- "macos-arm64": "@codexhost/cli-darwin-arm64",
- "macos-x64": "@codexhost/cli-darwin-x64",
- "windows-x64": "@codexhost/cli-win32-x64",
- "windows-arm64": "@codexhost/cli-win32-arm64",
- "linux-x64": "@codexhost/cli-linux-x64",
- "linux-arm64": "@codexhost/cli-linux-arm64",
+ "macos-arm64": "@karlorz/codexhost-cli-darwin-arm64",
+ "macos-x64": "@karlorz/codexhost-cli-darwin-x64",
+ "windows-x64": "@karlorz/codexhost-cli-win32-x64",
+ "windows-arm64": "@karlorz/codexhost-cli-win32-arm64",
+ "linux-x64": "@karlorz/codexhost-cli-linux-x64",
+ "linux-arm64": "@karlorz/codexhost-cli-linux-arm64",
});
+
+export function requireForkNpmIdentity(packageName) {
+ if (!packageName.startsWith("@karlorz/")) {
+ throw new Error(
+ `refusing to publish ${packageName}; fork npm packages must use the @karlorz scope`,
+ );
+ }
+}
+
+requireForkNpmIdentity(NPM_PACKAGE_NAME);
+for (const packageName of Object.values(NPM_PLATFORM_PACKAGE_NAMES)) {
+ requireForkNpmIdentity(packageName);
+}
+
+export function npmPackageRepositoryFields() {
+ return {
+ repository: {
+ type: "git",
+ url: `git+https://github.com/${NPM_GITHUB_REPOSITORY}.git`,
+ },
+ bugs: { url: `https://github.com/${NPM_GITHUB_REPOSITORY}/issues` },
+ homepage: `https://github.com/${NPM_GITHUB_REPOSITORY}#readme`,
+ };
+}
export const NPM_RUNTIME_PLATFORM_PACKAGES = Object.freeze({
"darwin-arm64": NPM_PLATFORM_PACKAGE_NAMES["macos-arm64"],
"darwin-x64": NPM_PLATFORM_PACKAGE_NAMES["macos-x64"],
@@ -248,6 +273,7 @@ export function expectedNpmPackagePaths(target) {
"app/desktop-controller.mjs",
"app/host-runtime.mjs",
"app/renderer-extension.js",
+ "app/cpa-live-voice-hook.js",
...preinstalledHarnessPluginPaths(),
"licenses/Anthropic-SDK-LICENSE.txt",
"licenses/Agent-Client-Protocol-SDK-LICENSE.txt",
@@ -288,14 +314,7 @@ export function createNpmPackageManifest({ version, target }) {
os: npmPackageOs(target),
cpu: npmPackageCpu(target),
keywords: ["codex", "codexhost", "pi", "claude-code", "agent", "harness"],
- repository: {
- type: "git",
- url: "git+https://github.com/BytePioneer-AI/codex-host.git",
- },
- bugs: {
- url: "https://github.com/BytePioneer-AI/codex-host/issues",
- },
- homepage: "https://github.com/BytePioneer-AI/codex-host#readme",
+ ...npmPackageRepositoryFields(),
publishConfig: {
access: "public",
},
@@ -313,7 +332,7 @@ import { fileURLToPath } from "node:url";
const version = ${JSON.stringify(version)};
const userArguments = process.argv.slice(2);
-const repositoryUrl = "https://github.com/BytePioneer-AI/codex-host";
+const repositoryUrl = ${JSON.stringify(`https://github.com/${NPM_GITHUB_REPOSITORY}`)};
const startupTraceStartedAt = Date.now();
function startupTrace(stage) {
if (process.env.CODEXHOST_STARTUP_TRACE !== "1") return;
@@ -392,7 +411,7 @@ try {
}
if (platformVersion !== version) {
fail(
- \`platform package version mismatch: '\${platformPackage}' at '\${packageRoot}' has \${JSON.stringify(platformVersion) ?? "no version"}; expected \${version}. Close Codex Desktop, then run: npm install -g @codexhost/cli@\${version} \${platformPackage}@\${version}\`,
+ \`platform package version mismatch: '\${platformPackage}' at '\${packageRoot}' has \${JSON.stringify(platformVersion) ?? "no version"}; expected \${version}. Close Codex Desktop, then run: npm install -g ${NPM_PACKAGE_NAME}@\${version} \${platformPackage}@\${version}\`,
);
}
startupTrace("platform package resolved");
@@ -943,6 +962,7 @@ export async function validateNpmPackage({ packageRoot, target, root }) {
"app/console-web.js",
"app/desktop-controller.mjs",
"app/renderer-extension.js",
+ "app/cpa-live-voice-hook.js",
].includes(file.relative)
) {
forbiddenReferences.push("@anthropic-ai/", "@codexhost/adapter-claude-code");
@@ -1139,6 +1159,11 @@ export async function prepareNpmPackage({
path.join(packageRoot, "app", "renderer-extension.js"),
"production Renderer Bundle",
);
+ await copyReleaseFile(
+ path.join(root, "packages", "renderer-extension", "dist", "cpa-live-voice-hook.js"),
+ path.join(packageRoot, "app", "cpa-live-voice-hook.js"),
+ "CPA live-voice overlay hook Bundle",
+ );
await copyReleaseFile(
path.join(root, "packages", "renderer-extension", "dist", "console.js"),
path.join(packageRoot, "app", "console-web.js"),
diff --git a/scripts/release/prepare-payload.mjs b/scripts/release/prepare-payload.mjs
index b670de999..7a507b28f 100644
--- a/scripts/release/prepare-payload.mjs
+++ b/scripts/release/prepare-payload.mjs
@@ -263,6 +263,7 @@ export function expectedPayloadPaths(target) {
"app/desktop-controller.mjs",
"app/host-runtime.mjs",
"app/renderer-extension.js",
+ "app/cpa-live-voice-hook.js",
...preinstalledHarnessPluginPaths(),
"licenses/Node.js-LICENSE.txt",
"licenses/Agent-Client-Protocol-SDK-LICENSE.txt",
@@ -327,6 +328,7 @@ export async function validatePayload({ payloadRoot, target, root }) {
"app/console-web.js",
"app/desktop-controller.mjs",
"app/renderer-extension.js",
+ "app/cpa-live-voice-hook.js",
].includes(file.relative)
) {
forbiddenReferences.push("@anthropic-ai/", "@codexhost/adapter-claude-code");
@@ -434,6 +436,11 @@ export async function prepareReleasePayload({ target, root = repositoryRoot }) {
path.join(payloadRoot, "app", "renderer-extension.js"),
"production Renderer Bundle",
);
+ await copyReleaseFile(
+ path.join(root, "packages", "renderer-extension", "dist", "cpa-live-voice-hook.js"),
+ path.join(payloadRoot, "app", "cpa-live-voice-hook.js"),
+ "CPA live-voice overlay hook Bundle",
+ );
await copyReleaseFile(
path.join(root, "packages", "renderer-extension", "dist", "console.js"),
path.join(payloadRoot, "app", "console-web.js"),
diff --git a/scripts/release/publish-npm.mjs b/scripts/release/publish-npm.mjs
index df570e480..c56db66e2 100644
--- a/scripts/release/publish-npm.mjs
+++ b/scripts/release/publish-npm.mjs
@@ -3,7 +3,12 @@ import { readdir } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
-import { NPM_PACKAGE_NAME, npmPlatformPackageName, npmTarballFileName } from "./prepare-npm.mjs";
+import {
+ NPM_PACKAGE_NAME,
+ npmPlatformPackageName,
+ npmTarballFileName,
+ requireForkNpmIdentity,
+} from "./prepare-npm.mjs";
import { npmMetaTarballFileName } from "./prepare-npm-meta.mjs";
import { supportedReleaseTargets, releaseTarget } from "./targets.mjs";
@@ -91,6 +96,7 @@ export function npmPublishCommand(entry, { dryRun, provenance, registry, tag })
export function publishNpmPlan(plan, options) {
for (const entry of plan) {
+ requireForkNpmIdentity(entry.packageName);
const command = npmPublishCommand(entry, options);
console.log(
`${options.dryRun ? "checking" : "publishing"} ${entry.packageName}@${entry.version}`,
diff --git a/tests/release/npm-package.test.mjs b/tests/release/npm-package.test.mjs
index 061bd61f8..398eb76cd 100644
--- a/tests/release/npm-package.test.mjs
+++ b/tests/release/npm-package.test.mjs
@@ -90,6 +90,12 @@ async function createHomebrewNodeLayout(root) {
await copyFile(process.execPath, cellarNode);
await chmod(cellarNode, 0o755);
}
+ // Homebrew Node can load libnode relative to its executable.
+ await symlink(
+ path.join(path.dirname(path.dirname(process.execPath)), "lib"),
+ path.join(path.dirname(path.dirname(cellarNode)), "lib"),
+ "dir",
+ );
await writeExecutable(prefixNpm, '#!/usr/bin/env node\nconsole.log("homebrew-prefix-npm");\n');
await writeExecutable(libexecNpm, '#!/usr/bin/env node\nconsole.log("homebrew-libexec-npm");\n');
await mkdir(path.join(brewPrefix, "bin"), { recursive: true });
@@ -107,15 +113,14 @@ async function createHomebrewNodeLayout(root) {
async function createGlobalCodexhostInstall(prefix) {
const platformPackage =
process.platform === "win32"
- ? `@codexhost/cli-win32-${process.arch}`
- : `@codexhost/cli-darwin-${process.arch}`;
+ ? `@karlorz/codexhost-cli-win32-${process.arch}`
+ : `@karlorz/codexhost-cli-darwin-${process.arch}`;
const packageRoot = path.join(prefix, "lib", "node_modules", platformPackage);
const launcherPath = path.join(
prefix,
"lib",
"node_modules",
- "@codexhost",
- "cli",
+ ...NPM_PACKAGE_NAME.split("/"),
"bin",
"codexhost.js",
);
@@ -171,8 +176,14 @@ async function createNpmMetaPackageFixture(root) {
}
async function createLauncherLifecycleFixture(root, platform) {
- const launcherPath = path.join(root, "node_modules", "@codexhost", "cli", "bin", "codexhost.js");
- const platformPackage = `@codexhost/cli-${platform}-x64`;
+ const launcherPath = path.join(
+ root,
+ "node_modules",
+ ...NPM_PACKAGE_NAME.split("/"),
+ "bin",
+ "codexhost.js",
+ );
+ const platformPackage = `@karlorz/codexhost-cli-${platform}-x64`;
const platformRoot = path.join(root, "node_modules", ...platformPackage.split("/"));
const executableSuffix = platform === "win32" ? ".exe" : "";
const npmCliPath = path.join(root, "npm-cli.js");
@@ -241,7 +252,7 @@ async function runLauncherLifecycle(
await createLauncherLifecycleFixture(root, platform);
await writeFile(
path.join(platformRoot, "package.json"),
- JSON.stringify({ name: `@codexhost/cli-${platform}-x64`, version: platformVersion }),
+ JSON.stringify({ name: `@karlorz/codexhost-cli-${platform}-x64`, version: platformVersion }),
);
const environment = {
...process.env,
@@ -450,7 +461,7 @@ describe("npm package release", () => {
it("publishes a scoped platform package with platform constraints", () => {
const target = releaseTarget("macos-arm64");
const manifest = createNpmPackageManifest({ version: "0.1.0", target });
- expect(manifest.name).toBe("@codexhost/cli-darwin-arm64");
+ expect(manifest.name).toBe("@karlorz/codexhost-cli-darwin-arm64");
expect(npmPlatformPackageName(target)).toBe(manifest.name);
expect(manifest.private).toBeUndefined();
expect(manifest.bin).toBeUndefined();
@@ -491,9 +502,9 @@ describe("npm package release", () => {
it("injects package resources when the user runs codexhost with no args", () => {
const source = createNpmBinLauncherSource({ version: "0.1.0" });
- expect(source).toContain('"darwin-arm64": "@codexhost/cli-darwin-arm64"');
- expect(source).toContain('"linux-x64": "@codexhost/cli-linux-x64"');
- expect(source).toContain('"linux-arm64": "@codexhost/cli-linux-arm64"');
+ expect(source).toContain('"darwin-arm64": "@karlorz/codexhost-cli-darwin-arm64"');
+ expect(source).toContain('"linux-x64": "@karlorz/codexhost-cli-linux-x64"');
+ expect(source).toContain('"linux-arm64": "@karlorz/codexhost-cli-linux-arm64"');
expect(source).toContain("require.resolve");
expect(source).toContain("--omit=optional");
expect(source).toContain('launchArguments = ["launch"]');
@@ -631,7 +642,7 @@ describe("npm package release", () => {
expect(result.stderr).toContain("Launcher exited after ready");
expect(result.stdout).toContain(
"⭐ If this project helps you, please give us a Star ⭐\n" +
- "https://github.com/BytePioneer-AI/codex-host",
+ "https://github.com/karlorz/codex-host",
);
expect(readme).toContain("On Windows, the command remains attached until Codex Desktop exits");
expect(readme).toContain("process trees of completed commands");
@@ -644,10 +655,10 @@ describe("npm package release", () => {
const result = await runLauncherLifecycle(platform, { platformVersion });
expect(result.status, result.stderr).toBe(1);
expect(result.stderr).toContain("platform package version mismatch");
- expect(result.stderr).toContain(`@codexhost/cli-${platform}-x64`);
+ expect(result.stderr).toContain(`@karlorz/codexhost-cli-${platform}-x64`);
expect(result.stderr).toContain("expected 0.1.0");
expect(result.stderr).toContain(
- `npm install -g @codexhost/cli@0.1.0 @codexhost/cli-${platform}-x64@0.1.0`,
+ `npm install -g @karlorz/codexhost-cli@0.1.0 @karlorz/codexhost-cli-${platform}-x64@0.1.0`,
);
expect(result.stderr).not.toContain("received Launcher ready");
expect(result.stdout).not.toContain("startup:");
@@ -664,7 +675,7 @@ describe("npm package release", () => {
expect(result.stderr).not.toContain("Launcher exited after ready");
expect(result.stdout).toBe(
"⭐ If this project helps you, please give us a Star ⭐\n" +
- "https://github.com/BytePioneer-AI/codex-host\n",
+ "https://github.com/karlorz/codex-host\n",
);
});
@@ -674,7 +685,7 @@ describe("npm package release", () => {
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe(
"⭐ 如果这个项目对你有帮助,请给我们一个 Star ⭐\n" +
- "https://github.com/BytePioneer-AI/codex-host\n",
+ "https://github.com/karlorz/codex-host\n",
);
});
@@ -685,12 +696,12 @@ describe("npm package release", () => {
expect(colored.status, colored.stderr).toBe(0);
expect(colored.stdout).toBe(
"\u001B[33m⭐ If this project helps you, please give us a Star ⭐\u001B[0m\n" +
- "\u001B[36mhttps://github.com/BytePioneer-AI/codex-host\u001B[0m\n",
+ "\u001B[36mhttps://github.com/karlorz/codex-host\u001B[0m\n",
);
expect(plain.status, plain.stderr).toBe(0);
expect(plain.stdout).toBe(
"⭐ If this project helps you, please give us a Star ⭐\n" +
- "https://github.com/BytePioneer-AI/codex-host\n",
+ "https://github.com/karlorz/codex-host\n",
);
});
@@ -868,14 +879,14 @@ describe("npm package release", () => {
path.resolve(import.meta.dirname, "../../scripts/release/prepare-npm.mjs"),
"utf8",
);
- expect(source).toContain('NPM_PACKAGE_NAME = "@codexhost/cli"');
+ expect(source).toContain('NPM_PACKAGE_NAME = "@karlorz/codexhost-cli"');
expect(Object.values(NPM_PLATFORM_PACKAGE_NAMES)).toEqual([
- "@codexhost/cli-darwin-arm64",
- "@codexhost/cli-darwin-x64",
- "@codexhost/cli-win32-x64",
- "@codexhost/cli-win32-arm64",
- "@codexhost/cli-linux-x64",
- "@codexhost/cli-linux-arm64",
+ "@karlorz/codexhost-cli-darwin-arm64",
+ "@karlorz/codexhost-cli-darwin-x64",
+ "@karlorz/codexhost-cli-win32-x64",
+ "@karlorz/codexhost-cli-win32-arm64",
+ "@karlorz/codexhost-cli-linux-x64",
+ "@karlorz/codexhost-cli-linux-arm64",
]);
expect(source).toContain("publishConfig");
expect(source).toContain('access: "public"');
diff --git a/tests/release/npm-publish.test.mjs b/tests/release/npm-publish.test.mjs
index 53c2ea72f..193807c09 100644
--- a/tests/release/npm-publish.test.mjs
+++ b/tests/release/npm-publish.test.mjs
@@ -49,13 +49,13 @@ describe("npm registry publishing", () => {
for (const name of names) await writeFile(path.join(root, "nested", name), name);
const plan = await createNpmPublishPlan({ artifactsRoot: root, version });
expect(plan.map((entry) => entry.packageName)).toEqual([
- "@codexhost/cli-darwin-arm64",
- "@codexhost/cli-darwin-x64",
- "@codexhost/cli-win32-x64",
- "@codexhost/cli-win32-arm64",
- "@codexhost/cli-linux-x64",
- "@codexhost/cli-linux-arm64",
- "@codexhost/cli",
+ "@karlorz/codexhost-cli-darwin-arm64",
+ "@karlorz/codexhost-cli-darwin-x64",
+ "@karlorz/codexhost-cli-win32-x64",
+ "@karlorz/codexhost-cli-win32-arm64",
+ "@karlorz/codexhost-cli-linux-x64",
+ "@karlorz/codexhost-cli-linux-arm64",
+ "@karlorz/codexhost-cli",
]);
expect(plan.at(-1).kind).toBe("meta");
} finally {
diff --git a/tests/release/payload.test.mjs b/tests/release/payload.test.mjs
index 84e4742e7..92a70e7bd 100644
--- a/tests/release/payload.test.mjs
+++ b/tests/release/payload.test.mjs
@@ -92,9 +92,9 @@ describe("release Payload", () => {
await createPayload(root, target);
const paths = await validatePayload({ payloadRoot: root, target, root: "/repo/source" });
expect(paths).toEqual(expectedPayloadPaths(target));
- expect(paths).toHaveLength(28 + preinstalledHarnessPluginPaths().length);
+ expect(paths).toHaveLength(29 + preinstalledHarnessPluginPaths().length);
expect(expectedPayloadPaths(releaseTarget("windows-x64"))).toHaveLength(
- 30 + preinstalledHarnessPluginPaths().length,
+ 31 + preinstalledHarnessPluginPaths().length,
);
expect(paths).toContain("licenses/tailwindcss-LICENSE.txt");
expect(paths).toContain("app/plugins/enabled.json");
diff --git a/tests/release/production-renderer.test.mjs b/tests/release/production-renderer.test.mjs
index fc672e032..84752ab74 100644
--- a/tests/release/production-renderer.test.mjs
+++ b/tests/release/production-renderer.test.mjs
@@ -73,7 +73,10 @@ describe("production Renderer release chain", () => {
expect(rendererBuild).toContain("src/production-entry.ts");
expect(rendererBuild).toContain("dist/production.js");
+ expect(rendererBuild).toContain("src/cpa-live-voice-entry.ts");
+ expect(rendererBuild).toContain("dist/cpa-live-voice-hook.js");
expect(releaseBuilder).toContain('dist", "production.js');
+ expect(releaseBuilder).toContain('dist", "cpa-live-voice-hook.js');
expect(releaseBuilder).toContain("desktop-controller.mjs");
expect(releaseBuilder).toContain('packageName: "lucide"');
expect(releaseBuilder).toContain("lucide-LICENSE.txt");
From 3e1debe706dd10623d75fa50b1fdd5e16a0e9536 Mon Sep 17 00:00:00 2001
From: karlorz <142769090+karlorz@users.noreply.github.com>
Date: Fri, 16 Oct 2026 23:48:02 +0800
Subject: [PATCH 02/98] feat: lock in-app Updates to karlorz/codex-host (#2)
Keep GitHub latest discovery, Settings commands, and console NOTICE on this fork so hyphen versions are not treated as an upgrade from BytePioneer.
---
docs/NOTICE.md | 2 +-
docs/operations/codexhost-console.md | 2 +-
docs/operations/fork-versioning.md | 4 +-
.../github-release-background-update/spec.md | 2 +-
packages/console-server/src/announcement.ts | 3 +-
.../console-server/test/announcement.test.ts | 30 +++++++
packages/console-server/test/updates.test.ts | 76 ++++++++++++-----
.../test/settings/pages.test.ts | 12 +++
.../test/github-release.test.ts | 81 ++++++++++++++++---
9 files changed, 175 insertions(+), 37 deletions(-)
create mode 100644 packages/console-server/test/announcement.test.ts
diff --git a/docs/NOTICE.md b/docs/NOTICE.md
index 7c1a77097..42035611e 100644
--- a/docs/NOTICE.md
+++ b/docs/NOTICE.md
@@ -12,4 +12,4 @@ type: warning
- 用户需要采取的临时措施。
- 修复进度或预计恢复时间。
-[查看项目发布记录](https://github.com/BytePioneer-AI/codex-host/releases)
+[查看项目发布记录](https://github.com/karlorz/codex-host/releases)
diff --git a/docs/operations/codexhost-console.md b/docs/operations/codexhost-console.md
index 6dfa4287c..0f41fffb0 100644
--- a/docs/operations/codexhost-console.md
+++ b/docs/operations/codexhost-console.md
@@ -21,7 +21,7 @@ title: 版本兼容性提醒
type: warning
---
-这里填写公告正文,可使用 **粗体**、列表和 [链接](https://github.com/BytePioneer-AI/codex-host)。
+这里填写公告正文,可使用 **粗体**、列表和 [链接](https://github.com/karlorz/codex-host)。
```
- `enabled`:仅 `true` 显示,`false` 隐藏;正文为空也隐藏。
diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md
index 1fbf5a2c7..40aff1c0d 100644
--- a/docs/operations/fork-versioning.md
+++ b/docs/operations/fork-versioning.md
@@ -29,7 +29,7 @@ Fork operators install and update with:
npm install -g @karlorz/codexhost-cli@latest
```
-That is the Settings → Updates manual command. **Update and restart** on an npm install runs the same package at the GitHub latest version (via the Updater), not `@codexhost/cli`.
+That is the Settings → Updates manual command. Settings auto-checks `karlorz/codex-host` `/releases/latest` when Updates is opened. A current version that already matches that latest tag is up to date. A higher fork counter is offered only after that version is published as the fork's non-draft, non-prerelease GitHub latest Release. **Update and restart** on an npm install then runs `@karlorz/codexhost-cli` at that GitHub latest version (via the Updater). Installation requires an explicit user action.
| Channel | Fork identity | Do not use |
| --- | --- | --- |
@@ -42,6 +42,8 @@ A fork tag is not shipped until both of these exist for that version:
1. **npm:** attended `npm publish` of the packed meta package and matching platform tarballs under the `@karlorz` scope, dist-tag `latest` (or `next` only if the tag is an npm prerelease channel). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`.
2. **GitHub:** a **latest** (non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build.
+The standard `Release packages` workflow classifies hyphenated SemVer tags as prereleases and publishes them with `--prerelease --latest=false`. An attended fork counter release must explicitly use non-prerelease GitHub Release status and become latest to appear in Updates; the numeric tag alone does not make it discoverable.
+
Keep GitHub Actions workflow `Release packages` **disabled** until it is wired to publish `@karlorz/*` with a Trusted Publisher on `karlorz/codex-host`. Do not enable it while it would still publish `@codexhost/*`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
## GitHub Actions secrets
diff --git a/openspec/specs/github-release-background-update/spec.md b/openspec/specs/github-release-background-update/spec.md
index 480becd89..00f651054 100644
--- a/openspec/specs/github-release-background-update/spec.md
+++ b/openspec/specs/github-release-background-update/spec.md
@@ -4,7 +4,7 @@
TBD - created by archiving change implement-github-release-update-flow. Update Purpose after archive.
## Requirements
### Requirement: Host discovers one stable GitHub Release
-The update capability SHALL discover the latest stable codexhost version only from `https://api.github.com/repos/BytePioneer-AI/codex-host/releases/latest`, SHALL compare its `v`-prefixed tag with the packaged current SemVer, and SHALL keep check failure non-blocking for normal application launch. It MUST NOT require a custom manifest, checksum sidecar, codexhost server, login, or embedded GitHub credential.
+The update capability SHALL discover the latest stable codexhost version only from `https://api.github.com/repos/karlorz/codex-host/releases/latest`, SHALL compare its `v`-prefixed tag with the packaged current SemVer, and SHALL keep check failure non-blocking for normal application launch. It MUST NOT require a custom manifest, checksum sidecar, codexhost server, login, or embedded GitHub credential.
#### Scenario: New stable Release exists
- **WHEN** GitHub returns a non-draft, non-prerelease Release with a valid newer `v` tag
diff --git a/packages/console-server/src/announcement.ts b/packages/console-server/src/announcement.ts
index 4a8df2b4c..141631dd2 100644
--- a/packages/console-server/src/announcement.ts
+++ b/packages/console-server/src/announcement.ts
@@ -1,7 +1,6 @@
import { consoleAnnouncementSchema, type ConsoleAnnouncement } from "@codexhost/shared-contracts";
-const ANNOUNCEMENT_URL =
- "https://raw.githubusercontent.com/BytePioneer-AI/codex-host/main/docs/NOTICE.md";
+const ANNOUNCEMENT_URL = "https://raw.githubusercontent.com/karlorz/codex-host/main/docs/NOTICE.md";
const MAX_DOCUMENT_BYTES = 16 * 1024;
const TIMEOUT_MS = 5_000;
diff --git a/packages/console-server/test/announcement.test.ts b/packages/console-server/test/announcement.test.ts
new file mode 100644
index 000000000..9cc2d60ff
--- /dev/null
+++ b/packages/console-server/test/announcement.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { readAnnouncement } from "../src/announcement.js";
+
+describe("console announcement source", () => {
+ it("reads the fork NOTICE.md through the announcement fetch path", async () => {
+ const fetcher = vi.fn(
+ async () =>
+ new Response("---\nenabled: true\ntitle: Fork update\ntype: info\n---\nFork release notes"),
+ );
+
+ await expect(readAnnouncement(fetcher)).resolves.toEqual({
+ title: "Fork update",
+ type: "info",
+ body: "Fork release notes",
+ });
+ expect(fetcher).toHaveBeenCalledOnce();
+ expect(fetcher).toHaveBeenCalledWith(
+ "https://raw.githubusercontent.com/karlorz/codex-host/main/docs/NOTICE.md",
+ expect.objectContaining({ redirect: "error", cache: "no-store" }),
+ );
+ });
+
+ it("hides an unavailable fork announcement without retrying", async () => {
+ const fetcher = vi.fn(async () => new Response(null, { status: 404 }));
+
+ await expect(readAnnouncement(fetcher)).resolves.toBeNull();
+ expect(fetcher).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/console-server/test/updates.test.ts b/packages/console-server/test/updates.test.ts
index 0077591cf..18ca16797 100644
--- a/packages/console-server/test/updates.test.ts
+++ b/packages/console-server/test/updates.test.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { EventEmitter } from "node:events";
@@ -18,14 +18,24 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
-const release = {
- version: "1.1.0",
- releaseNotes: "Fixes",
- releaseNotesUrl: "https://github.com/karlorz/codex-host/releases/tag/v1.1.0",
- assets: [],
-};
+function releaseFor(version: string) {
+ return {
+ version,
+ releaseNotes: "Fixes",
+ releaseNotesUrl: `https://github.com/karlorz/codex-host/releases/tag/v${version}`,
+ assets: [],
+ };
+}
-async function npmLayout(platform: NodeJS.Platform = process.platform) {
+const release = releaseFor("1.1.0");
+
+async function writeWaitingForExit(statusPath: string): Promise {
+ const status = JSON.parse(await readFile(statusPath, "utf8"));
+ await writeFile(`${statusPath}.tmp`, JSON.stringify({ ...status, phase: "waiting-for-exit" }));
+ await rename(`${statusPath}.tmp`, statusPath);
+}
+
+async function npmLayout(platform: NodeJS.Platform = process.platform, version = "1.0.0") {
const packageRoot = path.join(root, "package");
const appDirectory = path.join(packageRoot, "app");
await mkdir(path.join(packageRoot, "libexec"), { recursive: true });
@@ -41,7 +51,7 @@ async function npmLayout(platform: NodeJS.Platform = process.platform) {
await writeFile(npmCli, "");
await writeFile(wrapper, "");
const target: ConsoleUpdateTarget = {
- distribution: { schemaVersion: 1, version: "1.0.0", distribution: "npm", target: "linux-x64" },
+ distribution: { schemaVersion: 1, version, distribution: "npm", target: "linux-x64" },
appDirectory,
runtimeDescriptorPath: path.join(root, "runtime", "desktop-runtime-v1.json"),
codexhostRunning: false,
@@ -142,8 +152,29 @@ describe("console updates", () => {
await expect(createConsoleUpdates(options).status()).resolves.toEqual({ status: null });
});
- it("hands off to the Updater before returning", async () => {
- const { target, environment } = await npmLayout("linux");
+ it("reports an equal fork counter as up to date without starting the Updater", async () => {
+ const { target, environment } = await npmLayout("linux", "0.13.2-1");
+ const updates = createConsoleUpdates({
+ onHandedOff: vi.fn(),
+ environment,
+ stateDirectory: path.join(root, "state"),
+ fetchLatest: async () => releaseFor("0.13.2-1"),
+ });
+
+ await expect(updates.check(target)).resolves.toMatchObject({
+ currentVersion: "0.13.2-1",
+ latestVersion: "0.13.2-1",
+ updateAvailable: false,
+ installationAvailable: false,
+ error: null,
+ });
+ });
+
+ it.each([
+ ["1.0.0", "1.1.0"],
+ ["0.13.2-1", "0.13.2-2"],
+ ])("hands off %s → %s to the Updater before returning", async (currentVersion, latestVersion) => {
+ const { target, environment } = await npmLayout("linux", currentVersion);
const spawnUpdater = vi.fn<(executable: string, requestPath: string) => never>(
() => Object.assign(new EventEmitter(), { pid: 4321, kill: vi.fn() }) as never,
);
@@ -156,30 +187,34 @@ describe("console updates", () => {
processId: 777,
processExecutable: process.execPath,
stateDirectory: path.join(root, "state"),
- fetchLatest: async () => release,
+ fetchLatest: async () => releaseFor(latestVersion),
manager: createBackgroundUpdateManager({ platform: "linux", spawnUpdater }),
});
- await updates.check(target);
+ await expect(updates.check(target)).resolves.toMatchObject({
+ currentVersion,
+ latestVersion,
+ updateAvailable: true,
+ installationAvailable: true,
+ });
+ expect(spawnUpdater).not.toHaveBeenCalled();
+ expect(onHandedOff).not.toHaveBeenCalled();
const starting = updates.start(target);
await vi.waitFor(() => expect(spawnUpdater).toHaveBeenCalledOnce());
expect(onHandedOff).not.toHaveBeenCalled();
const call = spawnUpdater.mock.calls[0];
if (!call) throw new Error("Updater was not spawned");
const spawnedRequest = JSON.parse(await readFile(call[1], "utf8"));
- const status = JSON.parse(await readFile(spawnedRequest.status_path, "utf8"));
- await writeFile(
- spawnedRequest.status_path,
- JSON.stringify({ ...status, phase: "waiting-for-exit" }),
- );
+ await writeWaitingForExit(spawnedRequest.status_path);
const result = await starting;
expect(result.status.phase).toBe("waiting-for-exit");
- expect(result.status).toMatchObject({ version: "1.1.0", installation: "npm" });
+ expect(result.status).toMatchObject({ version: latestVersion, installation: "npm" });
expect(onHandedOff).toHaveBeenCalledOnce();
const requestPath = (spawnUpdater.mock.calls[0] as unknown as [string, string])[1];
const request = JSON.parse(await readFile(requestPath, "utf8")) as Record;
expect(request).toMatchObject({
+ version: latestVersion,
wait_pid: 777,
wait_executable: process.execPath,
runtime_descriptor_path: target.runtimeDescriptorPath,
@@ -214,8 +249,7 @@ describe("console updates", () => {
const call = spawnUpdater.mock.calls[0];
if (!call) throw new Error("Updater was not spawned");
const request = JSON.parse(await readFile(call[1], "utf8"));
- const status = JSON.parse(await readFile(request.status_path, "utf8"));
- await writeFile(request.status_path, JSON.stringify({ ...status, phase: "waiting-for-exit" }));
+ await writeWaitingForExit(request.status_path);
await expect(retry).resolves.toMatchObject({ status: { phase: "waiting-for-exit" } });
await expect(updates.status()).resolves.toMatchObject({
status: { phase: "waiting-for-exit", error: null },
diff --git a/packages/renderer-extension/test/settings/pages.test.ts b/packages/renderer-extension/test/settings/pages.test.ts
index 1608660f0..a90cf2b5b 100644
--- a/packages/renderer-extension/test/settings/pages.test.ts
+++ b/packages/renderer-extension/test/settings/pages.test.ts
@@ -38,6 +38,8 @@ import { RendererSessionImportUnavailableError } from "../../src/renderer-sessio
const HARNESS_SESSION_LIST_METHOD = "codexhost/harness/session-import/list";
const HARNESS_SESSION_IMPORT_METHOD = "codexhost/harness/session-import/import";
import {
+ CODEXHOST_GITHUB_REPOSITORY_URL,
+ CODEXHOST_NPM_MANUAL_UPDATE_COMMAND,
CODEXHOST_RELEASES_LATEST_URL,
createDefaultRendererSettingsPages,
} from "../../src/settings/pages.js";
@@ -1942,6 +1944,16 @@ describe("Renderer Codex Accounts page", () => {
});
describe("Renderer Updates page", () => {
+ it("points Settings Updates at karlorz/codex-host and @karlorz/codexhost-cli", () => {
+ expect(CODEXHOST_GITHUB_REPOSITORY_URL).toBe("https://github.com/karlorz/codex-host");
+ expect(CODEXHOST_RELEASES_LATEST_URL).toBe(
+ "https://github.com/karlorz/codex-host/releases/latest",
+ );
+ expect(CODEXHOST_NPM_MANUAL_UPDATE_COMMAND).toBe(
+ "npm install -g @karlorz/codexhost-cli@latest",
+ );
+ });
+
it.each(["succeeded", "failed"] as const)(
"keeps polling after start/status timeouts until the Host reports %s",
async (terminalPhase) => {
diff --git a/packages/update-manager/test/github-release.test.ts b/packages/update-manager/test/github-release.test.ts
index db409b79a..8997c7a8f 100644
--- a/packages/update-manager/test/github-release.test.ts
+++ b/packages/update-manager/test/github-release.test.ts
@@ -9,8 +9,25 @@ import {
parseLatestGitHubRelease,
selectInstallerReleaseArtifact,
type GitHubCliRunner,
+ type InstallerReleaseTarget,
} from "@codexhost/update-manager";
+function installerAsset(
+ version: string,
+ target: InstallerReleaseTarget,
+ overrides: Record = {},
+) {
+ const name = expectedInstallerAssetName(version, target);
+ return {
+ name,
+ size: 42,
+ digest: `sha256:${"ab".repeat(32)}`,
+ browser_download_url: `https://github.com/karlorz/codex-host/releases/download/v${version}/${name}`,
+ uploader: { login: "karlorz" },
+ ...overrides,
+ };
+}
+
function release(overrides: Record = {}) {
return {
tag_name: "v1.2.3",
@@ -18,16 +35,7 @@ function release(overrides: Record = {}) {
draft: false,
prerelease: false,
body: "## Changes\n\n- Safer updates",
- assets: [
- {
- name: "codexhost-1.2.3-windows-x64.exe",
- size: 42,
- digest: `sha256:${"ab".repeat(32)}`,
- browser_download_url:
- "https://github.com/karlorz/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
- uploader: { login: "github-actions" },
- },
- ],
+ assets: [installerAsset("1.2.3", "windows-x64")],
author: { login: "github-actions" },
...overrides,
};
@@ -51,6 +59,56 @@ describe("GitHub Release update discovery", () => {
);
});
+ it("discovers only karlorz/codex-host latest and rejects BytePioneer assets", () => {
+ expect(CODEXHOST_LATEST_RELEASE_URL).toBe(
+ "https://api.github.com/repos/karlorz/codex-host/releases/latest",
+ );
+ expect(() =>
+ parseLatestGitHubRelease(
+ release({
+ html_url: "https://github.com/BytePioneer-AI/codex-host/releases/tag/v1.2.3",
+ }),
+ ),
+ ).toThrow("does not match");
+ expect(() =>
+ parseLatestGitHubRelease(
+ release({
+ assets: [
+ installerAsset("1.2.3", "windows-x64", {
+ browser_download_url:
+ "https://github.com/BytePioneer-AI/codex-host/releases/download/v1.2.3/codexhost-1.2.3-windows-x64.exe",
+ }),
+ ],
+ }),
+ ),
+ ).toThrow("invalid");
+ });
+
+ it("parses a fork hyphen version and selects the matching installer asset", () => {
+ const parsed = parseLatestGitHubRelease(
+ release({
+ tag_name: "v0.13.2-1",
+ html_url: "https://github.com/karlorz/codex-host/releases/tag/v0.13.2-1",
+ assets: [installerAsset("0.13.2-1", "macos-arm64")],
+ }),
+ );
+ expect(parsed.version).toBe("0.13.2-1");
+ expect(parsed.releaseNotesUrl).toBe(
+ "https://github.com/karlorz/codex-host/releases/tag/v0.13.2-1",
+ );
+ expect(expectedInstallerAssetName("0.13.2-1", "macos-arm64")).toBe(
+ "codexhost-0.13.2-1-macos-arm64.dmg",
+ );
+ expect(selectInstallerReleaseArtifact(parsed, "macos-arm64")).toEqual({
+ name: "codexhost-0.13.2-1-macos-arm64.dmg",
+ source: {
+ url: "https://github.com/karlorz/codex-host/releases/download/v0.13.2-1/codexhost-0.13.2-1-macos-arm64.dmg",
+ sha256: "ab".repeat(32),
+ size: 42,
+ },
+ });
+ });
+
it("rejects prereleases, mismatched notes, duplicate assets, and unverified selection", () => {
expect(() => parseLatestGitHubRelease(release({ prerelease: true }))).toThrow("invalid");
expect(() =>
@@ -95,6 +153,9 @@ describe("GitHub Release update discovery", () => {
expect(compareSemanticVersions("1.2.3-test.2", "1.2.3")).toBeLessThan(0);
expect(compareSemanticVersions("1.2.4", "1.2.3")).toBeGreaterThan(0);
expect(compareSemanticVersions("1.2.3+one", "1.2.3+two")).toBe(0);
+ expect(compareSemanticVersions("0.13.2-0", "0.13.2-1")).toBeLessThan(0);
+ expect(compareSemanticVersions("0.13.2-1", "0.13.2-1")).toBe(0);
+ expect(compareSemanticVersions("0.13.2-1", "0.13.2")).toBeLessThan(0);
});
it("requests only the fixed GitHub latest endpoint", async () => {
From a27d307b90a7aa445212b45ed1e5d31cea1ac9c2 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:49:51 +0900
Subject: [PATCH 03/98] fix: treat numeric fork counters as GitHub and npm
latest
Fork tags like 0.13.2-2 must publish as latest so in-app Updates can discover them. Named prereleases stay on next/test.
---
docs/operations/fork-versioning.md | 4 +---
packages/repository-automation/src/release.mjs | 10 ++++++++--
.../repository-automation/test/release.test.mjs | 13 ++++++++-----
3 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md
index 40aff1c0d..e2c9df61f 100644
--- a/docs/operations/fork-versioning.md
+++ b/docs/operations/fork-versioning.md
@@ -42,9 +42,7 @@ A fork tag is not shipped until both of these exist for that version:
1. **npm:** attended `npm publish` of the packed meta package and matching platform tarballs under the `@karlorz` scope, dist-tag `latest` (or `next` only if the tag is an npm prerelease channel). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`.
2. **GitHub:** a **latest** (non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build.
-The standard `Release packages` workflow classifies hyphenated SemVer tags as prereleases and publishes them with `--prerelease --latest=false`. An attended fork counter release must explicitly use non-prerelease GitHub Release status and become latest to appear in Updates; the numeric tag alone does not make it discoverable.
-
-Keep GitHub Actions workflow `Release packages` **disabled** until it is wired to publish `@karlorz/*` with a Trusted Publisher on `karlorz/codex-host`. Do not enable it while it would still publish `@codexhost/*`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
+Numeric fork counters such as `0.13.2-2` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`) stay on `next` / `test` and GitHub prerelease. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
## GitHub Actions secrets
diff --git a/packages/repository-automation/src/release.mjs b/packages/repository-automation/src/release.mjs
index fe1aa441e..327208624 100644
--- a/packages/repository-automation/src/release.mjs
+++ b/packages/repository-automation/src/release.mjs
@@ -80,6 +80,7 @@ export async function readReleaseMetadata({ root, tag, expectedRefSha }) {
}
if (!notes.trim()) throw new Error("annotated tag body must contain release notes");
const prereleaseId = version.split("+")[0].split("-").slice(1).join("-");
+ const numericForkCounter = /^\d+$/u.test(prereleaseId);
return {
tag,
version,
@@ -87,8 +88,13 @@ export async function readReleaseMetadata({ root, tag, expectedRefSha }) {
tagObjectSha,
notes,
automationSha: automationSha.trim(),
- prerelease: Boolean(prereleaseId),
- npmTag: !prereleaseId ? "latest" : /^test(?:\.|$)/u.test(prereleaseId) ? "test" : "next",
+ prerelease: Boolean(prereleaseId) && !numericForkCounter,
+ npmTag:
+ !prereleaseId || numericForkCounter
+ ? "latest"
+ : /^test(?:\.|$)/u.test(prereleaseId)
+ ? "test"
+ : "next",
};
}
diff --git a/packages/repository-automation/test/release.test.mjs b/packages/repository-automation/test/release.test.mjs
index a04f8380d..d156518c4 100644
--- a/packages/repository-automation/test/release.test.mjs
+++ b/packages/repository-automation/test/release.test.mjs
@@ -109,12 +109,15 @@ describe("release source and metadata", () => {
});
it.each([
- ["1.2.3-test.1", "test"],
- ["1.2.3-beta.1", "next"],
- ["1.2.3+build.1", "latest"],
- ])("retains npm routing for %s", async (version, npmTag) => {
+ ["1.2.3", "latest", false],
+ ["0.13.2-1", "latest", false],
+ ["0.13.2-2", "latest", false],
+ ["1.2.3-test.1", "test", true],
+ ["1.2.3-beta.1", "next", true],
+ ["1.2.3+build.1", "latest", false],
+ ])("retains npm routing for %s", async (version, npmTag, prerelease) => {
const f = await repository({ version });
- expect((await readReleaseMetadata(f)).npmTag).toBe(npmTag);
+ expect(await readReleaseMetadata(f)).toMatchObject({ npmTag, prerelease });
});
it("rejects lightweight tags and empty release-note bodies", async () => {
From beca52ceaa45ab7310ba697001c13ef0e75fd4ae Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sat, 17 Oct 2026 00:49:59 +0900
Subject: [PATCH 04/98] chore: prepare v0.13.2-2
---
Cargo.lock | 10 +++++-----
Cargo.toml | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 134609827..95027f300 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "codexhost-gate-a-native"
-version = "0.13.2-1"
+version = "0.13.2-2"
dependencies = [
"codexhost-platform",
"codexhost-shim",
@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "codexhost-launcher"
-version = "0.13.2-1"
+version = "0.13.2-2"
dependencies = [
"codexhost-platform",
"fs2",
@@ -106,7 +106,7 @@ dependencies = [
[[package]]
name = "codexhost-platform"
-version = "0.13.2-1"
+version = "0.13.2-2"
dependencies = [
"libproc",
"nix",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "codexhost-shim"
-version = "0.13.2-1"
+version = "0.13.2-2"
dependencies = [
"codexhost-platform",
"fs2",
@@ -131,7 +131,7 @@ dependencies = [
[[package]]
name = "codexhost-updater"
-version = "0.13.2-1"
+version = "0.13.2-2"
dependencies = [
"codexhost-platform",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 985314740..cc5c6bfa6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ default-members = [
resolver = "3"
[workspace.package]
-version = "0.13.2-1"
+version = "0.13.2-2"
edition = "2024"
rust-version = "1.97.1"
publish = false
diff --git a/package-lock.json b/package-lock.json
index f4b7766f7..d3e0730ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexhost",
- "version": "0.13.2-1",
+ "version": "0.13.2-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexhost",
- "version": "0.13.2-1",
+ "version": "0.13.2-2",
"license": "LGPL-3.0-only",
"workspaces": [
"packages/*",
diff --git a/package.json b/package.json
index 6d9be7eb2..1d00adf4c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexhost",
- "version": "0.13.2-1",
+ "version": "0.13.2-2",
"private": true,
"license": "LGPL-3.0-only",
"type": "module",
From 635d958dea3dc3429e276edd9a3e78cf78494ae0 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:29:18 +0900
Subject: [PATCH 05/98] docs: record shipped v0.13.2-2 fork release path
---
.agents/skills/codexhost-release/SKILL.md | 2 +-
docs/index.md | 2 +-
docs/operations/codexhost-console.md | 2 +-
docs/operations/fork-versioning.md | 15 +++++++++------
4 files changed, 12 insertions(+), 9 deletions(-)
diff --git a/.agents/skills/codexhost-release/SKILL.md b/.agents/skills/codexhost-release/SKILL.md
index 232fdb6ad..33a845ed6 100644
--- a/.agents/skills/codexhost-release/SKILL.md
+++ b/.agents/skills/codexhost-release/SKILL.md
@@ -39,7 +39,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
用户意图不清楚时,先问清两个渠道的目标。最终执行前明确报告版本、npm dist-tag、GitHub 状态与是否提示更新。
-在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 仍为 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 在未改成 `@karlorz` Trusted Publisher 前保持 disabled。控制台「codexhost 版本」必须与该准备版本一致。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
+在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 仍为 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`、`v0.13.2-2`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 已启用,Trusted Publisher 指向 `karlorz/codex-host`;数字计数(如 `0.13.2-2`)由工作流发成 npm `latest` + GitHub latest。控制台「codexhost 版本」必须与该准备版本一致。`gh pr create` / `gh release` 必须带 `-R karlorz/codex-host`。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
## 2. 检查现场与验证
diff --git a/docs/index.md b/docs/index.md
index fb1c17f50..a114aebf6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -130,7 +130,7 @@
| [`operations/remote-thread-read.md`](operations/remote-thread-read.md) | 通过现有 Desktop 远程连接跨 Harness 读取会话;包含部署要求、分页、响应大小与超时限制。 |
| [`operations/host-runtime-log.md`](operations/host-runtime-log.md) | Host Runtime 日志文件的位置、内容与边界;排查 Runtime 崩溃或异常退出时阅读。 |
| [`operations/codexhost-console.md`](operations/codexhost-console.md) | 本地控制台的打开方式、页面、项目公告、与运行中 Host 的连接、端口、诊断文件、安全与更新边界;排查启动失败或维护 Web 公告时阅读。 |
-| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`…;npm 发 `@karlorz/codexhost-cli`;in-app Update 读 fork GitHub latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
+| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`…;已发 `v0.13.2-2` 为 GitHub latest 与 npm `@karlorz/codexhost-cli@latest`;数字计数走 `Release packages` latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
| [`NOTICE.md`](NOTICE.md) | Web 控制台打开或刷新时读取的项目公告,含显示开关、标题、类型与正文;默认关闭,发布规则见控制台文档。 |
| [`operations/repository-maintenance.md`](operations/repository-maintenance.md) | PR 标签、CI 评论和发布前检查自动化;修改仓库自动化时阅读。 |
diff --git a/docs/operations/codexhost-console.md b/docs/operations/codexhost-console.md
index 0f41fffb0..0983044ce 100644
--- a/docs/operations/codexhost-console.md
+++ b/docs/operations/codexhost-console.md
@@ -2,7 +2,7 @@
控制台是一个本地网页,不依赖 Codex Desktop 启动成功。左侧导航分为:
-- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。安装包上的 **codexhost 版本** 来自 `app/codexhost-distribution.json`。`release:prepare` 只同步 `package.json` / lock / Cargo;distribution 文件由随后的 `release:package` 或 `release:npm` 写出。本 fork 上该数字必须等于当前 fork 标签,例如 upstream 仍为 `0.13.2` 时显示 `0.13.2-0`,见 [fork-versioning.md](fork-versioning.md)。热装现有 app 时需重打包或直接改写已安装的 `codexhost-distribution.json`。
+- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。安装包上的 **codexhost 版本** 来自 `app/codexhost-distribution.json`。`release:prepare` 只同步 `package.json` / lock / Cargo;distribution 文件由随后的 `release:package` 或 `release:npm` 写出。本 fork 上该数字必须等于当前 fork 标签,例如已发 `v0.13.2-2` 时显示 `0.13.2-2`,见 [fork-versioning.md](fork-versioning.md)。热装现有 app 时需重打包或直接改写已安装的 `codexhost-distribution.json`。
- **设置**:连接、远程连接、账号、会话导入、更新,与 Codex 设置页使用同一套页面和 Host 接口。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。
- **其他**:关于。
diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md
index e2c9df61f..a5218ed2f 100644
--- a/docs/operations/fork-versioning.md
+++ b/docs/operations/fork-versioning.md
@@ -39,10 +39,12 @@ That is the Settings → Updates manual command. Settings auto-checks `karlorz/c
A fork tag is not shipped until both of these exist for that version:
-1. **npm:** attended `npm publish` of the packed meta package and matching platform tarballs under the `@karlorz` scope, dist-tag `latest` (or `next` only if the tag is an npm prerelease channel). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`.
-2. **GitHub:** a **latest** (non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build.
+1. **npm:** `Release packages` publishes the packed meta package and matching platform tarballs under `@karlorz` with dist-tag `latest` (or `next` / `test` only for named SemVer prereleases). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`. After CI logs `+ @karlorz/…@`, npm may still 404 for a few minutes while provenance is processed; recurl the version URL before republishing.
+2. **GitHub:** a **latest** (non-draft, non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build.
-Numeric fork counters such as `0.13.2-2` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`) stay on `next` / `test` and GitHub prerelease. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
+Numeric fork counters such as `0.13.2-2` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`) stay on `next` / `test` and GitHub prerelease. `packages/repository-automation/src/release.mjs` treats a hyphen plus digits-only (`0.13.2-2`) as latest, not as `next`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
+
+Shipped latest on this fork is `v0.13.2-2`: GitHub latest plus `@karlorz/codexhost-cli@0.13.2-2` and all six platform packages on npm `latest`.
## GitHub Actions secrets
@@ -68,14 +70,15 @@ for line in Path.home().joinpath(".npmrc").read_text().splitlines():
gh secret set FORK_SYNC_TOKEN -R karlorz/codex-host
```
-Keep `Release packages` disabled until npm Trusted Publisher on `@karlorz/codexhost-cli` (and the six platform packages) is the karlorz GitHub repo.
+`Release packages` is enabled on `karlorz/codex-host`. npm Trusted Publisher for `@karlorz/codexhost-cli` and the six platform packages is this GitHub repo (`id-token: write` + `--provenance`). A tag push of a numeric fork counter on `main` after that SHA's CI is green is the ship path. Do not disable the workflow for a normal latest release.
## Release channel on this fork
- Never `git push` to `upstream`.
- Never publish `@codexhost/*` from this checkout.
- Do not move, delete, or re-push an existing tag unless the operator explicitly requested that rewrite.
-- Create GitHub Releases with `-R karlorz/codex-host`. `gh release create --notes-from-tag` does not work with `--repo`; write the tag annotation to a notes file instead. A `gh` invocation without `-R` may resolve to BytePioneer and must abort.
+- Create GitHub Releases and pull requests with `-R karlorz/codex-host`. Default `gh` in this checkout resolves to BytePioneer-AI/codex-host. `gh pr create` without `-R` opened the wrong-repo PR #547 and must abort. `gh release create --notes-from-tag` does not work with `--repo`; write the tag annotation to a notes file instead.
+- After the lock is on `origin/main`, the next command is `npm run release:prepare -- 0.13.2-` on `main`. Merge of an identity-lock PR is not a release.
## Next unused counter
@@ -86,4 +89,4 @@ git fetch origin --tags --prune
git ls-remote --tags origin 'refs/tags/v0.13.2-*'
```
-Pick the smallest `n` that is not already a fork tag. Then `npm run release:prepare -- 0.13.2-` on a branch, with the usual release-skill checks before any tag push.
+Shipped tags include `v0.13.2-0`, `v0.13.2-1`, and `v0.13.2-2`. Pick the smallest `n` that is not already a fork tag (next is `0.13.2-3`). Then `npm run release:prepare -- 0.13.2-` on `main`, push the four version files, wait for that SHA's `main` push CI, then push the annotated tag.
From ff7fe5b0047b91fa964ed742b1c16443171f2a02 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sat, 17 Oct 2026 16:59:17 +0900
Subject: [PATCH 06/98] feat: hide Codex usage warning and lift the usage send
gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Hide the native out-of-usage card on every Composer from Settings →
Appearance (default on), including asides that omit status ARIA. Project
this Composer's account and reserve usage gates so Send is not blocked by
exhausted ChatGPT quota, including Codex threads and same-atom owner or
descendant subscriptions.
---
.../codexhost-update-impact-audit/SKILL.md | 8 +-
docs/index.md | 2 +-
...odex-desktop-upgrade-diagnosis-playbook.md | 13 +-
docs/operations/codexhost-console.md | 2 +-
docs/product/codex-accounts.md | 4 +-
.../src/renderer-binding-probe.ts | 25 ++-
.../renderer-codex-usage-banner-preference.ts | 22 +++
.../src/renderer-codex-usage-gate.ts | 139 +++++++++++-----
.../src/renderer-composer-dom.ts | 51 +++++-
.../src/settings/appearance-page.ts | 32 ++++
.../src/settings/localization.ts | 20 +++
.../test/renderer-binding-probe.test.ts | 148 ++++++++++++++++++
...erer-codex-usage-banner-preference.test.ts | 56 +++++++
.../test/renderer-codex-usage-gate.test.ts | 116 ++++++++++++--
.../test/settings/localization.test.ts | 4 +
tools/codex-desktop-contract-audit/README.md | 4 +-
tools/codex-desktop-contract-audit/report.mjs | 13 +-
.../report.test.mjs | 1 +
18 files changed, 585 insertions(+), 75 deletions(-)
create mode 100644 packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
create mode 100644 packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
diff --git a/.agents/skills/codexhost-update-impact-audit/SKILL.md b/.agents/skills/codexhost-update-impact-audit/SKILL.md
index 8ac006b90..8e502269a 100644
--- a/.agents/skills/codexhost-update-impact-audit/SKILL.md
+++ b/.agents/skills/codexhost-update-impact-audit/SKILL.md
@@ -128,15 +128,15 @@ The runner does not accept the `.app` directory. A controlled flow may verify Ag
### Codex usage gate probe
-This surface lets an external Harness submit while the ChatGPT-signed-in Codex subscription is out of usage by projecting `false` for one Composer's two Desktop usage-gate subscriptions. It depends on private React/state-library internals, so check it on every update. `npm run audit:codex-desktop` reports its structure as the `codex-usage-gate` surface (see `tools/codex-desktop-contract-audit/README.md`); `codex-usage-account-gate-dormant` means the audit ran without a ChatGPT sign-in whose Account gate reads usage. The audit does not verify behavior.
+This surface lets an external Harness submit while the ChatGPT-signed-in Codex subscription is out of usage by projecting `false` for one Composer's verified Desktop usage-gate subscriptions. It depends on private React/state-library internals, so check it on every update. `npm run audit:codex-desktop` reports its structure as the `codex-usage-gate` surface (see `tools/codex-desktop-contract-audit/README.md`); `codex-usage-account-gate-dormant` means the audit ran without a ChatGPT sign-in whose Account gate reads usage. The audit does not verify behavior. Raw subscription counts may exceed one per logical gate when the same store+atom is subscribed on the owner and a descendant.
-Bundle evidence (evidence only, not contracts): the Composer owner combines `submitDisabled` from an Account rate-limit selector (`authMethod !== "chatgpt"` early return, then `rate_limit.allowed === false`, gated to host `local`) and a reserve `hardBlocked` selector. Confirm both still exist and still feed `submitDisabled`, and that no new Account-wide usage blocker was added beside them. If Desktop now exempts external models/Threads or stops blocking in the Renderer, report that the module can be removed.
+Bundle evidence (evidence only, not contracts): the Composer owner combines `submitDisabled` from an Account rate-limit selector (`authMethod !== "chatgpt"` early return, then `rate_limit.allowed === false`, with a local `reserve.active` exemption, gated to host `local`) and a reserve `hardBlocked` selector. Confirm both still exist and still feed `submitDisabled`, and that no new Account-wide usage blocker was added beside them. If Desktop now exempts external models/Threads or stops blocking in the Renderer, report that the module can be removed.
Manual read-only live probe, when the audit surface is not `no-impact` or its counts need explaining, in a Composer inside the primary Renderer. Do not patch `getSnapshot`, subscribe to the store, or call effect functions:
1. From the editor's nearest React host, walk committed ancestors; exactly one owner has `onLocalSubmitStart` and boolean `submitDisabled`, in both a new draft and an existing Thread.
-2. In its hook list, find subscriber memo candidates with dependencies `[store, atom]` or Desktop 26.928's `[signal, undefined]`, where the readonly signal exposes `atom`, `store`, `get`, and `subscribe`. The next hook has `queue = { value: boolean, getSnapshot }`, followed by effect `{ create, deps: [subscriber.subscribe] }`, without `atom.write`, and `createRender()` returning nothing. An instance getter may equal the subscriber getter or be a lazy wrapper; for an unbound wrapper, both snapshots must equal the native boolean `store.get(atom)`.
-3. Replay each candidate's `atom.read` with tracing proxies, following readonly boolean selector indirections with cycle and work bounds. Exactly one reads `hardBlocked` without `active` or Account gate fields (reserve). When signed in with ChatGPT, exactly one reads `authMethod` and `rate_limit.allowed` without reserve fields (Account). Each replayed boolean must equal its native value; reject mixed gates.
+2. In that owner and its descendants, find subscriber memo candidates with dependencies `[store, atom]` or Desktop 26.928's `[signal, undefined]`, where the readonly signal exposes `atom`, `store`, `get`, and `subscribe`. The next hook has `queue = { value: boolean, getSnapshot }`, followed by effect `{ create, deps: [subscriber.subscribe] }`, without `atom.write`, and `createRender()` returning nothing. An instance getter may equal the subscriber getter or be a lazy wrapper; for an unbound wrapper, both snapshots must equal the native boolean `store.get(atom)`.
+3. Replay each candidate's `atom.read` with tracing proxies, following readonly boolean selector indirections with cycle and work bounds. Exactly one logical reserve group reads `hardBlocked` without `active` or Account gate fields; every member shares store and atom. When signed in with ChatGPT, the Account group reads `authMethod` and `rate_limit.allowed`; it may also read `reserve.active` as a local exemption. Reject any member that also reads `hardBlocked`, and reject mixed store/atom identities within a kind. Each replayed boolean must equal its native value.
4. `store.sub`, `subscriber.getSnapshot`, and the instance `getSnapshot` are writable.
With codexhost running and an external Agent selected, bound gates show an instance `getSnapshot` of `() => false` for both hooks. The Agent control's hover title shows the "cannot separate from Codex usage" message when binding failed.
diff --git a/docs/index.md b/docs/index.md
index a114aebf6..65bd02011 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -100,7 +100,7 @@
| 文档 | 内容与阅读时机 |
| --- | --- |
-| [`product/codex-accounts.md`](product/codex-accounts.md) | 设置页中的原生账号与额度布局、外部 Harness 不受 Codex 额度门限制、手动导入 Pi 及导入记录管理;修改账号 UI、发送额度门、查询或凭证导入链路时阅读。 |
+| [`product/codex-accounts.md`](product/codex-accounts.md) | 设置页中的原生账号与额度布局、外部 Harness 不受 Codex 额度门限制、外观页「隐藏 Codex 额度警告」(默认开启)、手动导入 Pi 及导入记录管理;修改账号 UI、发送额度门、额度横幅或凭证导入链路时阅读。 |
| [`product/usage-metering.md`](product/usage-metering.md) | 外部 Harness 与官方 Codex 的会话用量、插件可选能力、实时刷新、费用、缓存命中、首字延迟及速度限制;修改计量、价格表或 `pricing.json` 时阅读。 |
| [`product/usage-statistics.md`](product/usage-statistics.md) | 控制台与 Desktop 内置设置的全局用量统计:独立 Codex 统计插件、各 Harness 原生会话去重、按模型 ID 读时计价与缓存;新增读取器或修改统计口径时阅读。 |
| [`product/codex-native-account-switching-design.md`](product/codex-native-account-switching-design.md) | 移除 Codex 多账号切换后的只读边界;修改 Codex 认证或账号路由时阅读。 |
diff --git a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
index 30ade5e67..55be5d33f 100644
--- a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
+++ b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
@@ -328,22 +328,23 @@ grok
### 检查 Codex 额度门
-ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 disabled,且 Agent 控件悬停提示「无法将此 Harness 与 Codex 额度限制分离」,说明 `renderer-codex-usage-gate.ts` 没有绑定成功。API Key 登录不会触发这道门,不能用来复现。
+ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 disabled,且 Agent 控件悬停提示「无法将此 Harness 与 Codex 额度限制分离」,说明 `renderer-codex-usage-gate.ts` 没有绑定成功。API Key 登录不会触发这道门,不能用来复现。额度耗尽横幅是否显示由设置 → 外观「隐藏 Codex 额度警告」控制(默认开启,作用于所有 Composer,包括 Codex 会话);横幅隐藏成功不能代替这道发送门的绑定检查。
在主 Renderer 中只读检查(不修改 getSnapshot,不订阅 store):
```text
1. 从编辑器向上找到唯一带 onLocalSubmitStart 和布尔 submitDisabled 的组件
-2. 在其 hook 链中找「subscriber memo → useSyncExternalStore inst → 订阅 effect」三连:
+2. 在该组件及其下级子树的 hook 链中找「subscriber memo → useSyncExternalStore inst
+ → 订阅 effect」三连(新版本 Desktop 把门订阅在接收 submitDisabled 的下级组件上):
- memo 依赖为 [store, atom],或 26.928 的 [readonly signal adapter, undefined]
- adapter 提供 atom/store/get/subscribe;atom 不得可写;effect 依赖 subscriber.subscribe
- inst.getSnapshot 可以是 subscriber.getSnapshot,也可以是 lazy createRender wrapper;
wrapper 的布尔快照必须与 subscriber 和原生 atom 相同,且不能产生追踪型 render
3. 对每个布尔候选,用追踪代理重放 atom.read;26.928 的 signal 会间接读取 readonly
布尔 selector,需有界递归重放,拒绝循环、结果不一致和混合门:
- - 读取 hardBlocked 但不读取 active、账号门字段:reserve 门,必须恰好 1 个
- - 读取 authMethod 且读取 rate_limit.allowed,不读取 reserve 字段:账号门,
- 额度耗尽时必须恰好 1 个
+ - 读取 hardBlocked 但不读取 active、账号门字段:reserve 门,同一 store+atom 可有多份订阅
+ - 读取 authMethod 且读取 rate_limit.allowed:账号门;可以读取 reserve.active 作为本机豁免,
+ 不可再读取 hardBlocked。同一 store+atom 可有多份订阅(提交组件与下级各一份)
```
任一步骤数量不对,按 Desktop 新结构修改识别条件;不要改为按 hook 序号、压缩名或写入账号数据放行。若 Desktop 改为按 Thread、Model 或 host 豁免外部 Harness,或不再在 Renderer 中拦截,删除该模块。
@@ -454,7 +455,7 @@ ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 dis
- [ ] 点击 DeepSeek Harness 后 Model catalog 可加载
- [ ] 点击 Grok 后 Model catalog 可加载
- [ ] 切回 Codex 后原生 Composer 仍可用
-- [ ] ChatGPT 账号 Codex 额度耗尽时,新 Thread 和已有外部 Thread 均可发送;切回 Codex 后发送按钮恢复 disabled
+- [ ] ChatGPT 账号 Codex 额度耗尽时,新 Thread、已有外部 Thread 和 Codex Thread 的发送按钮均可点击(空输入等其他原生限制仍生效)
- [ ] 新 Thread 和未锁定 Thread 都完成切换验证
- [ ] 已锁定 Thread 仍按设计保持 locked
- [ ] 真实错误不会只被转换为笼统的 `Models unavailable`
diff --git a/docs/operations/codexhost-console.md b/docs/operations/codexhost-console.md
index 0983044ce..15c226a75 100644
--- a/docs/operations/codexhost-console.md
+++ b/docs/operations/codexhost-console.md
@@ -6,7 +6,7 @@
- **设置**:连接、远程连接、账号、会话导入、更新,与 Codex 设置页使用同一套页面和 Host 接口。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。
- **其他**:关于。
-外观(思考文本显示、空闲释放、改写实时语音)由 Codex 界面保存,只能在 Codex 设置页修改。本 fork 的改写实时语音默认开启;`~/.codex/config.toml` 里的 realtime 基址仍需自行配置。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
+外观(思考文本显示、隐藏 Codex 额度警告、空闲释放、改写实时语音)由 Codex 界面保存,只能在 Codex 设置页修改。隐藏 Codex 额度警告与改写实时语音均默认开启。`~/.codex/config.toml` 里的 realtime 基址仍需自行配置。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
## 项目公告
diff --git a/docs/product/codex-accounts.md b/docs/product/codex-accounts.md
index 8bb003625..2a1976743 100644
--- a/docs/product/codex-accounts.md
+++ b/docs/product/codex-accounts.md
@@ -24,9 +24,9 @@
ChatGPT 登录的 Codex 订阅额度耗尽时,Desktop 在 Renderer 中用两道账号级布尔门禁用 Composer 提交:账号额度门和 reserve `hardBlocked`。API Key 登录不经过这两道门。它们是界面上的订阅额度预检,不是协议限制;外部 Harness 的 `turn/start` 由 Host 路由,不会发到官方后端。
-因此在单个 Composer 选中外部 Agent、Adapter 就绪且没有 codexhost 自身的提交阻塞时,`renderer-codex-usage-gate.ts` 只把该 Composer 对这两道门的订阅快照投影为 `false`,继续走原生提交链路。不写账号、atom 或额度查询缓存,Codex 额度横幅保持显示;其他 Composer、Codex 路径和空输入、附件、运行中等其他原生限制不受影响。外部 Harness 的真实额度与错误由其自身处理。切回 Codex、Composer 移除或扩展卸载时恢复实时原生结果。
+因此在单个 Composer 没有 codexhost 自身的提交阻塞时(Codex,或外部 Agent 且 Adapter 就绪),`renderer-codex-usage-gate.ts` 只把该 Composer 对这两道门的订阅快照投影为 `false`,继续走原生提交链路。不写账号、atom 或额度查询缓存。设置 → 外观中的「隐藏 Codex 额度警告」默认开启:所有 Composer 上的官方额度耗尽横幅(Composer 内唯一带标题与 CTA 按钮的 `aside`,含 Reset usage / Upgrade;部分 Desktop 版本另带 `role="status"` 与 `aria-live="polite"`)都隐藏,包括 Codex 会话;关闭该开关后横幅恢复原生显示。空输入、附件、运行中等其他原生限制不受影响。官方 Codex 后端仍可能因额度拒绝该 Turn;外部 Harness 的真实额度与错误由其自身处理。Composer 移除或扩展卸载时恢复实时原生结果与横幅。
-门按其 selector 实际读取的字段识别,不依赖压缩名或 hook 序号。兼容直接 `[store, atom]` 订阅及 Desktop 26.928 的 readonly signal adapter / lazy snapshot wrapper:以成对的订阅 effect 和原生快照一致性验证 wrapper,限量重放 readonly 布尔 selector 的依赖以识别间接 reserve 门;拒绝循环依赖、追踪型 render、混合门和快照不匹配。释放时分别恢复 subscriber 与 React instance 原来的 getter,不把 wrapper 替换成另一个函数。无法唯一识别时保留原生限制,并在 Agent 控件悬停提示中说明。升级后的诊断步骤见 [Desktop 更新兼容性诊断手册](../operations/codex-desktop-upgrade-diagnosis-playbook.md#检查-codex-额度门)。
+门按其 selector 实际读取的字段识别,不依赖压缩名或 hook 序号;门可能挂在提交组件自身,也可能挂在接收 `submitDisabled` 的下级组件,两处都扫描。同一 store+atom 的多份订阅视为同一道逻辑门并一并投影;不同 atom 或 store 则保留原生限制。账号门可以读取 `reserve.active` 作为本机豁免;同时读取 `hardBlocked` 的混合选择器仍保留原生限制。兼容直接 `[store, atom]` 订阅及 Desktop 26.928 的 readonly signal adapter / lazy snapshot wrapper:以成对的订阅 effect 和原生快照一致性验证 wrapper,限量重放 readonly 布尔 selector 的依赖以识别间接 reserve 门;拒绝循环依赖、追踪型 render、混合门和快照不匹配。释放时分别恢复 subscriber 与 React instance 原来的 getter,不把 wrapper 替换成另一个函数。无法唯一识别时保留原生限制,并在 Agent 控件悬停提示中说明。升级后的诊断步骤见 [Desktop 更新兼容性诊断手册](../operations/codex-desktop-upgrade-diagnosis-playbook.md#检查-codex-额度门)。
## 其他 Harness 的只读账号额度
diff --git a/packages/renderer-extension/src/renderer-binding-probe.ts b/packages/renderer-extension/src/renderer-binding-probe.ts
index 7e311c412..47ad2a6d3 100644
--- a/packages/renderer-extension/src/renderer-binding-probe.ts
+++ b/packages/renderer-extension/src/renderer-binding-probe.ts
@@ -58,6 +58,10 @@ import {
TRANSCRIPT_ITEM_SELECTOR,
} from "./renderer-transcript-dom.js";
import { RendererCodexAccountState } from "./renderer-codex-account-state.js";
+import {
+ CODEX_USAGE_BANNER_CHANGE_EVENT,
+ CODEX_USAGE_BANNER_STORAGE_KEY,
+} from "./renderer-codex-usage-banner-preference.js";
import {
createRendererCodexUsageGate,
type RendererCodexUsageGate,
@@ -1009,7 +1013,7 @@ export function installRendererBindingProbe(
const currentCodexAccount = accounts?.accounts.find(
({ accountId }) => accountId === accounts.readyAccountId,
);
- const externalSubmissionReady = renderComposerAgentControl(
+ const usageGateAllowed = renderComposerAgentControl(
mounted.control,
controller.get(mounted.composer),
composerAdapterState(mounted),
@@ -1023,7 +1027,7 @@ export function installRendererBindingProbe(
currentCodexAccount ?? null,
mounted.ownershipStatus === "error",
);
- showCodexUsageGateStatus(mounted, mounted.codexUsageGate.update(externalSubmissionReady));
+ showCodexUsageGateStatus(mounted, mounted.codexUsageGate.update(usageGateAllowed));
if (mounted.control.usage) {
mounted.control.usage.onOpen = () => {
void refreshThreadUsage(
@@ -3122,10 +3126,25 @@ export function installRendererBindingProbe(
}
}
};
+ const onUsageBannerPreference = (): void => {
+ for (const mounted of mountedByComposer.values()) {
+ const hideCodexControls =
+ controller.isSwitching(mounted.composer) ||
+ controller.get(mounted.composer).agent !== "codex";
+ reconcileComposerNativeControls(mounted.control, hideCodexControls, hideCodexControls);
+ }
+ };
+ const onUsageBannerStorage = (event: StorageEvent): void => {
+ if (event.key === CODEX_USAGE_BANNER_STORAGE_KEY || event.key === null) {
+ onUsageBannerPreference();
+ }
+ };
window.addEventListener("codexhost:draft-prewarm-policy-changed", onHostRouteChange);
window.addEventListener("codexhost:draft-workspace", onDraftWorkspace);
window.addEventListener("codexhost:renderer-adapter-status", onAdapterStatus);
window.addEventListener("focus", onWindowFocus);
+ window.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, onUsageBannerPreference);
+ window.addEventListener("storage", onUsageBannerStorage);
delegationMention = installRendererDelegationMention(document, {
onOpen: (editor) => {
const composer = composerForElement(editor);
@@ -3319,6 +3338,8 @@ export function installRendererBindingProbe(
window.removeEventListener("codexhost:draft-workspace", onDraftWorkspace);
window.removeEventListener("codexhost:renderer-adapter-status", onAdapterStatus);
window.removeEventListener("focus", onWindowFocus);
+ window.removeEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, onUsageBannerPreference);
+ window.removeEventListener("storage", onUsageBannerStorage);
for (const state of harnessAvailabilityByHost.values()) {
state.requestGeneration += 1;
state.codexAccounts?.dispose();
diff --git a/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts b/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
new file mode 100644
index 000000000..340770707
--- /dev/null
+++ b/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
@@ -0,0 +1,22 @@
+export const CODEX_USAGE_BANNER_STORAGE_KEY = "codexhost.codex-usage-banner-hidden.v1";
+export const CODEX_USAGE_BANNER_CHANGE_EVENT = "codexhost:codex-usage-banner-changed";
+
+/** Hide the native Codex out-of-usage card on every Composer. Default on. */
+export function readCodexUsageBannerPreference(owner: Window | null | undefined): boolean {
+ if (!owner) return true;
+ try {
+ return owner.localStorage.getItem(CODEX_USAGE_BANNER_STORAGE_KEY) !== "0";
+ } catch {
+ return true;
+ }
+}
+
+export function writeCodexUsageBannerPreference(owner: Window, enabled: boolean): boolean {
+ try {
+ owner.localStorage.setItem(CODEX_USAGE_BANNER_STORAGE_KEY, enabled ? "1" : "0");
+ } catch {
+ return false;
+ }
+ owner.dispatchEvent(new Event(CODEX_USAGE_BANNER_CHANGE_EVENT));
+ return true;
+}
diff --git a/packages/renderer-extension/src/renderer-codex-usage-gate.ts b/packages/renderer-extension/src/renderer-codex-usage-gate.ts
index 49c242d59..de10d43f0 100644
--- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts
+++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts
@@ -1,14 +1,14 @@
/**
- * Lets an external Harness Composer submit while the signed-in ChatGPT Codex
- * subscription is out of usage.
+ * Lets a Composer submit while the signed-in ChatGPT Codex subscription is
+ * out of usage.
*
* Desktop blocks Composer submission through two boolean selectors: the
* ChatGPT Account rate-limit gate and the reserve `hardBlocked` gate. Both are
* Account-wide, only apply to ChatGPT sign-in, and are not a protocol limit.
* This module projects `false` for those two subscriptions of one Composer
- * only. It never writes Accounts, atoms or query data, so the Codex path and
- * other Composers keep the native result. Other native submit blockers are
- * unaffected. Anything that cannot be verified keeps the native restriction.
+ * only. It never writes Accounts, atoms or query data, so other Composers keep
+ * the native result. Other native submit blockers are unaffected. Anything
+ * that cannot be verified keeps the native restriction.
*/
import { committedReactAncestors } from "@codexhost/desktop-control/renderer-bindings";
@@ -63,9 +63,12 @@ function hooksOf(fiber: unknown): Hook[] {
}
/**
- * Composer components that combine native submit blockers into `submitDisabled`
- * and hold the reserve gate. Desktop wraps the owner in a pass-through component
- * with the same props but no gate hooks, so props alone are ambiguous.
+ * Composer components that combine native submit blockers into `submitDisabled`.
+ * Desktop wraps the owner in a pass-through component with the same props, so
+ * props alone are ambiguous: a candidate whose own hooks hold the reserve gate
+ * wins. When no candidate holds gates, they may be subscribed in a descendant;
+ * candidates are ordered innermost first, so the first candidate whose subtree
+ * holds the gates is the owner.
*/
function submitOwners(composer: Element): Fiber[] {
// The ProseMirror editor is not rendered by React; start at its nearest React host.
@@ -75,7 +78,7 @@ function submitOwners(composer: Element): Fiber[] {
name.startsWith("__reactFiber$"),
);
if (key) {
- const owners = committedReactAncestors(
+ const candidates = committedReactAncestors(
Object.getOwnPropertyDescriptor(element, key)?.value,
).filter(
({ memoizedProps: props }) =>
@@ -83,10 +86,11 @@ function submitOwners(composer: Element): Fiber[] {
"onLocalSubmitStart" in props &&
typeof props.submitDisabled === "boolean",
) as Fiber[];
- return owners.filter((owner) => {
- const gates = gateSubscriptions(owner);
+ const owner = candidates.find((fiber) => {
+ const gates = subtreeGateSubscriptions(fiber);
return !gates.invalid && gates.reserve.length > 0;
});
+ return owner ? [owner] : candidates;
}
element = element.parentElement;
}
@@ -203,8 +207,9 @@ function gateKind({ store, atom }: Subscription): GateKind | "mixed" | null {
}
const reserve = read.has("reserve.hardBlocked") && !read.has("reserve.active");
const account = read.has("auth.authMethod") && read.has("limit.allowed");
- // A combined selector cannot be separated into the two verified gates.
- if (account && (read.has("reserve.hardBlocked") || read.has("reserve.active"))) return "mixed";
+ // Account may read reserve.active as a local exemption. Mixing in
+ // hardBlocked cannot be separated from the reserve gate.
+ if (account && read.has("reserve.hardBlocked")) return "mixed";
if (reserve) return "reserve";
if (account) return "account";
return null;
@@ -237,9 +242,41 @@ function snapshotsMatch(
}
}
+function projectionFor(
+ projections: ReadonlyMap | undefined,
+ instance: Instance,
+): Projection | undefined {
+ return projections?.get(instance);
+}
+
+/** True when every subscription is the same store+atom pair, including none or one. */
+function sameLogicalGate(subscriptions: readonly Subscription[]): boolean {
+ const first = subscriptions[0];
+ if (!first) return true;
+ return subscriptions.every((item) => item.store === first.store && item.atom === first.atom);
+}
+
+type FiberNode = Fiber & { child?: unknown; sibling?: unknown };
+
+const MAX_SUBTREE_FIBERS = 1024;
+
+/** Committed descendant fibers of a component, bounded to one Composer. */
+function subtreeFibers(root: Fiber): Fiber[] {
+ const result: Fiber[] = [];
+ const stack: unknown[] = [(root as FiberNode).child];
+ while (stack.length > 0) {
+ const fiber = stack.pop();
+ if (!isRecord(fiber)) continue;
+ result.push(fiber as Fiber);
+ if (result.length >= MAX_SUBTREE_FIBERS) return [];
+ stack.push((fiber as FiberNode).child, (fiber as FiberNode).sibling);
+ }
+ return result;
+}
+
function gateSubscriptions(
owner: Fiber,
- projections?: ReadonlyMap,
+ projections?: ReadonlyMap,
): Record & { invalid: boolean } {
const found: Record & { invalid: boolean } = {
account: [],
@@ -250,7 +287,10 @@ function gateSubscriptions(
const subscription = subscriptionAt(hook);
const kind = subscription && gateKind(subscription);
if (subscription && kind) {
- if (kind !== "mixed" && snapshotsMatch(subscription, projections?.get(kind))) {
+ if (
+ kind !== "mixed" &&
+ snapshotsMatch(subscription, projectionFor(projections, subscription.instance))
+ ) {
found[kind].push(subscription);
} else found.invalid = true;
}
@@ -259,23 +299,44 @@ function gateSubscriptions(
}
/**
- * Gate subscriptions by kind; `null` when a kind is ambiguous. The account gate
- * returns early without reading usage when it cannot block (for example API-key
- * sign-in), so it may legitimately be absent until it reads `rate_limit`.
+ * Gate subscriptions anywhere under the submit owner, including the component
+ * that forwards `submitDisabled`. Several hooks of one kind are one logical
+ * gate when they share store and atom; different atoms stay ambiguous.
+ */
+function subtreeGateSubscriptions(
+ owner: Fiber,
+ projections?: ReadonlyMap,
+): Record & { invalid: boolean } {
+ const combined: Record & { invalid: boolean } = {
+ account: [],
+ reserve: [],
+ invalid: false,
+ };
+ for (const fiber of [owner, ...subtreeFibers(owner)]) {
+ const gates = gateSubscriptions(fiber, projections);
+ if (gates.invalid) combined.invalid = true;
+ combined.account.push(...gates.account);
+ combined.reserve.push(...gates.reserve);
+ }
+ if (!sameLogicalGate(combined.account) || !sameLogicalGate(combined.reserve)) {
+ combined.invalid = true;
+ }
+ return combined;
+}
+
+/**
+ * Gate subscriptions grouped by kind; `null` when a kind is ambiguous. The
+ * account gate returns early without reading usage when it cannot block (for
+ * example API-key sign-in), so it may legitimately be absent until it reads
+ * `rate_limit`.
*/
function discoverGates(
owner: Fiber,
- projections: ReadonlyMap,
-): Map | null {
- const gates = gateSubscriptions(owner, projections);
+ projections: ReadonlyMap,
+): Record | null {
+ const gates = subtreeGateSubscriptions(owner, projections);
if (gates.invalid) return null;
- const found = new Map();
- for (const kind of ["account", "reserve"] as const) {
- const subscriptions = gates[kind];
- if (subscriptions.length > 1) return null;
- if (subscriptions[0]) found.set(kind, subscriptions[0]);
- }
- return found;
+ return { account: gates.account, reserve: gates.reserve };
}
export interface RendererCodexUsageGateInspection {
@@ -287,7 +348,7 @@ export interface RendererCodexUsageGateInspection {
/** Read-only contract audit: counts only, never patches or subscribes. */
export function inspectComposerCodexUsageGate(composer: Element): RendererCodexUsageGateInspection {
const owners = submitOwners(composer);
- const gates = owners.length === 1 && owners[0] ? gateSubscriptions(owners[0]) : null;
+ const gates = owners.length === 1 && owners[0] ? subtreeGateSubscriptions(owners[0]) : null;
return {
ownerCount: owners.length,
reserveGateCount: gates?.reserve.length ?? 0,
@@ -345,8 +406,11 @@ function project({ subscriber, store, atom, instance, subscribeEffect }: Subscri
function isBound(owner: Fiber, projections: Iterable): boolean {
const alternate = isRecord(owner.alternate) ? owner.alternate : null;
const bound = [...projections];
- return [owner, alternate].some((fiber) => {
- const hooks = hooksOf(fiber);
+ const subtrees = [owner, alternate].flatMap((fiber) =>
+ fiber ? [[fiber, ...subtreeFibers(fiber)]] : [],
+ );
+ return subtrees.some((fibers) => {
+ const hooks = fibers.flatMap((fiber) => hooksOf(fiber));
return bound.every(({ subscriber, instance }) =>
hooks.some(
(hook) =>
@@ -361,7 +425,7 @@ function isBound(owner: Fiber, projections: Iterable): boolean {
export type RendererCodexUsageGateStatus = "native" | "bypassed" | "unsupported";
export interface RendererCodexUsageGate {
- /** `allowed`: this Composer submits to a ready external Harness. */
+ /** `allowed`: this Composer should lift the native Codex usage submit gate. */
update(allowed: boolean): RendererCodexUsageGateStatus;
/** Re-apply the last decision, rebinding if Desktop replaced the Composer hooks. */
refresh(): RendererCodexUsageGateStatus;
@@ -370,7 +434,7 @@ export interface RendererCodexUsageGate {
export function createRendererCodexUsageGate(composer: Element): RendererCodexUsageGate {
let owner: Fiber | null = null;
- const projections = new Map();
+ const projections = new Map();
let retryAt = 0;
let allowed = false;
const release = (): void => {
@@ -389,10 +453,12 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs
const current = owner ?? findSubmitOwner(composer);
const gates = current && discoverGates(current, projections);
// The reserve gate always reads its field, so its absence means an unknown contract.
- if (!current || !gates?.has("reserve")) throw new Error("Codex usage gate is unavailable");
+ if (!current || !gates || gates.reserve.length === 0) {
+ throw new Error("Codex usage gate is unavailable");
+ }
owner = current;
- for (const [kind, gate] of gates) {
- if (!projections.has(kind)) projections.set(kind, project(gate));
+ for (const gate of [...gates.reserve, ...gates.account]) {
+ if (!projections.has(gate.instance)) projections.set(gate.instance, project(gate));
}
return "bypassed";
};
@@ -406,7 +472,6 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs
release();
retryAt = 0;
}
- if (owner && projections.size === 2) return "bypassed";
if (Date.now() < retryAt) return owner ? "bypassed" : "unsupported";
retryAt = Date.now() + RETRY_DELAY_MS;
try {
diff --git a/packages/renderer-extension/src/renderer-composer-dom.ts b/packages/renderer-extension/src/renderer-composer-dom.ts
index 13e7bd35e..e7555b75c 100644
--- a/packages/renderer-extension/src/renderer-composer-dom.ts
+++ b/packages/renderer-extension/src/renderer-composer-dom.ts
@@ -50,6 +50,7 @@ import {
mountRendererHarnessCommandControl,
type RendererHarnessCommandControl,
} from "./renderer-harness-command-control.js";
+import { readCodexUsageBannerPreference } from "./renderer-codex-usage-banner-preference.js";
export { CONTROL_ATTRIBUTE };
export type ExternalModelControlView = RendererModelControlView;
@@ -91,6 +92,7 @@ export interface ComposerAgentControl {
nativeModelControl: NativeModelControlState | null;
nativePermissionModeControl: NativePermissionModeControlState | null;
nativeContextUsageControl?: NativeControlState | null;
+ nativeUsageBanner?: NativeControlState | null;
nativePermissionModeControlVerified: boolean;
credits: RendererCreditsControl;
usage: RendererUsageControl | null;
@@ -358,6 +360,24 @@ export function nativeContextUsageControlForComposer(composer: Element): HTMLEle
return candidates.length === 1 ? (candidates[0] ?? null) : null;
}
+/**
+ * Codex usage / upsell recovery banner above the Composer: an aside with a
+ * title and CTA buttons. Some Desktop versions also mark it as a polite
+ * status; others omit those ARIA attributes. Ambiguous matches stay visible.
+ */
+export function isNativeUsageBannerCandidate(element: Element): boolean {
+ if (element.localName !== "aside") return false;
+ if (typeof element.querySelector !== "function") return false;
+ return element.querySelector("h3") !== null && element.querySelector("button") !== null;
+}
+
+export function nativeUsageBannerForComposer(composer: Element): HTMLElement | null {
+ const candidates = [...composer.querySelectorAll("aside")].filter(
+ isNativeUsageBannerCandidate,
+ );
+ return candidates.length === 1 ? (candidates[0] ?? null) : null;
+}
+
function contractElementVisible(element: Element): boolean {
const typed = element as HTMLElement;
const bounds = typed.getBoundingClientRect?.();
@@ -452,6 +472,13 @@ function refreshNativeContextUsageControl(control: ComposerAgentControl): void {
control.nativeContextUsageControl = captureNativeControl(candidate);
}
+function refreshNativeUsageBanner(control: ComposerAgentControl): void {
+ const candidate = nativeUsageBannerForComposer(control.composer);
+ if (candidate === control.nativeUsageBanner?.element) return;
+ restoreNativeControl(control.nativeUsageBanner);
+ control.nativeUsageBanner = captureNativeControl(candidate);
+}
+
function refreshNativeModelControl(control: ComposerAgentControl): void {
const candidate = nativeModelControlForComposer(control.composer);
if (!candidate) return;
@@ -640,6 +667,7 @@ export function reconcileComposerNativeControls(
// it below, so Credits never reads a stale (e.g. mount-time fallback)
// location for it within this same pass.
refreshNativePermissionModeControl(control);
+ refreshNativeUsageBanner(control);
refreshTrailingClusterPlacement(control);
refreshUsagePlacement(control);
refreshCreditsPlacement(control);
@@ -649,6 +677,20 @@ export function reconcileComposerNativeControls(
// visible when the external Model control is substituted.
setNativeControlHidden(control.nativeContextUsageControl, false);
setNativeControlHidden(control.nativePermissionModeControl, hidePermissionMode);
+ // Settings → Appearance, default on: hide the account-wide Codex usage
+ // aside on every Composer, including Codex threads. The aside reports the
+ // ChatGPT Account rate limit and upsell, not a per-thread notice.
+ setNativeControlHidden(
+ control.nativeUsageBanner,
+ readCodexUsageBannerPreference(preferenceOwner(control)),
+ );
+}
+
+function preferenceOwner(control: ComposerAgentControl): Window | null {
+ return (
+ control.composer.ownerDocument?.defaultView ??
+ (typeof document !== "undefined" ? document.defaultView : null)
+ );
}
export function mountComposerAgentControl(
@@ -672,6 +714,7 @@ export function mountComposerAgentControl(
const nativeContextUsageControl = captureNativeControl(
nativeContextUsageControlForComposer(composer),
);
+ const nativeUsageBanner = captureNativeControl(nativeUsageBannerForComposer(composer));
const semanticNativePermissionModeControl =
semanticNativePermissionModeControlForComposer(composer);
const nativePermissionModeControl = captureNativeControl(semanticNativePermissionModeControl);
@@ -746,6 +789,7 @@ export function mountComposerAgentControl(
nativeModelControl,
nativePermissionModeControl,
nativeContextUsageControl,
+ nativeUsageBanner,
nativePermissionModeControlVerified,
credits,
usage: null,
@@ -771,8 +815,8 @@ function nativeSendDisabled(button: HTMLButtonElement, fallback: boolean): boole
}
/**
- * Returns whether this Composer is ready to submit to an external Harness:
- * not Codex, Adapter ready, and no codexhost submission blocker.
+ * Returns whether this Composer should lift the native Codex usage submit
+ * gate: no codexhost submission blocker, and either Codex or a ready Adapter.
*/
export function renderComposerAgentControl(
control: ComposerAgentControl,
@@ -869,7 +913,7 @@ export function renderComposerAgentControl(
control.harnessCommands.root.hidden = state.agent === "codex";
control.harnessCommands.root.style.display = state.agent === "codex" ? "none" : "inline-flex";
renderRendererCreditsControl(control.credits, accountCredits, locale);
- return state.agent !== "codex" && adapterState === "ready" && !submissionBlocked;
+ return !submissionBlocked && (state.agent === "codex" || adapterState === "ready");
}
export function disposeComposerAgentControl(control: ComposerAgentControl): void {
@@ -881,6 +925,7 @@ export function disposeComposerAgentControl(control: ComposerAgentControl): void
}
restoreNativeControl(control.nativeModelControl);
restoreNativeControl(control.nativeContextUsageControl);
+ restoreNativeControl(control.nativeUsageBanner);
restoreNativeControl(control.nativePermissionModeControl);
control.credits.dispose();
control.usage?.dispose();
diff --git a/packages/renderer-extension/src/settings/appearance-page.ts b/packages/renderer-extension/src/settings/appearance-page.ts
index 51607c188..424eeee0b 100644
--- a/packages/renderer-extension/src/settings/appearance-page.ts
+++ b/packages/renderer-extension/src/settings/appearance-page.ts
@@ -1,4 +1,9 @@
import { readCpaLiveVoiceHookConfig } from "../renderer-cpa-live-voice-hook.js";
+import {
+ CODEX_USAGE_BANNER_CHANGE_EVENT,
+ readCodexUsageBannerPreference,
+ writeCodexUsageBannerPreference,
+} from "../renderer-codex-usage-banner-preference.js";
import {
CPA_LIVE_VOICE_CHANGE_EVENT,
readCpaLiveVoicePreference,
@@ -41,6 +46,32 @@ export function createAppearanceSettingsPage(
description.textContent = messages.appearanceDescription;
const { group, card } = createPreferenceGroup(document, messages.appearanceGroup);
+ const usageBannerId = preferenceId("hide-codex-usage-banner");
+ const usageBannerRow = createPreferenceItem(document, {
+ title: messages.hideCodexUsageBannerTitle,
+ description: messages.hideCodexUsageBannerDescription,
+ controlId: usageBannerId,
+ help: {
+ label: messages.hideCodexUsageBannerHelpLabel,
+ lines: messages.hideCodexUsageBannerHelp,
+ },
+ });
+ const usageBanner = createPreferenceSwitch(
+ document,
+ usageBannerId,
+ usageBannerRow.description.id,
+ );
+ usageBanner.checked = readCodexUsageBannerPreference(ownerWindow);
+ usageBanner.addEventListener("change", () => {
+ writeCodexUsageBannerPreference(ownerWindow, usageBanner.checked);
+ });
+ const syncUsageBanner = (): void => {
+ usageBanner.checked = readCodexUsageBannerPreference(ownerWindow);
+ };
+ ownerWindow.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, syncUsageBanner);
+ usageBannerRow.item.append(usageBanner);
+ card.append(usageBannerRow.item);
+
const liveVoiceId = preferenceId("cpa-live-voice");
const liveVoiceRow = createPreferenceItem(document, {
title: messages.cpaLiveVoiceTitle,
@@ -92,6 +123,7 @@ export function createAppearanceSettingsPage(
getLoadedSessionsClient,
);
return () => {
+ ownerWindow.removeEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, syncUsageBanner);
ownerWindow.removeEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, syncLiveVoice);
ownerWindow.removeEventListener(REASONING_SOFT_WRAP_CHANGE_EVENT, sync);
disposeIdleRelease();
diff --git a/packages/renderer-extension/src/settings/localization.ts b/packages/renderer-extension/src/settings/localization.ts
index 88a89709c..bdaea37f3 100644
--- a/packages/renderer-extension/src/settings/localization.ts
+++ b/packages/renderer-extension/src/settings/localization.ts
@@ -38,6 +38,10 @@ export interface RendererSettingsMessages {
readonly otherSection: string;
readonly appearanceDescription: string;
readonly appearanceGroup: string;
+ readonly hideCodexUsageBannerTitle: string;
+ readonly hideCodexUsageBannerDescription: string;
+ readonly hideCodexUsageBannerHelpLabel: string;
+ readonly hideCodexUsageBannerHelp: readonly string[];
readonly cpaLiveVoiceTitle: string;
readonly cpaLiveVoiceDescription: string;
readonly cpaLiveVoiceHelpLabel: string;
@@ -295,6 +299,14 @@ const ENGLISH_MESSAGES: RendererSettingsMessages = Object.freeze({
otherSection: "Other",
appearanceDescription: "Conversation display and local resource management.",
appearanceGroup: "Appearance",
+ hideCodexUsageBannerTitle: "Hide Codex usage warning",
+ hideCodexUsageBannerDescription: "On by default. Hide the out-of-usage card on every Composer.",
+ hideCodexUsageBannerHelpLabel: "About hiding the Codex usage warning",
+ hideCodexUsageBannerHelp: Object.freeze([
+ "The native card appears when the signed-in ChatGPT Codex quota is exhausted.",
+ "When this is on, that card is hidden on every Composer, including Codex threads. Reset usage and Upgrade stay available in Settings.",
+ "Turning this off only shows the native card again. Send on this Composer, including Codex threads, is already separated from that quota card.",
+ ]),
cpaLiveVoiceTitle: "Override live voice",
cpaLiveVoiceDescription: "On by default. Host creates the realtime call instead of ChatGPT.",
cpaLiveVoiceHelpLabel: "About overriding live voice",
@@ -613,6 +625,14 @@ const CHINESE_MESSAGES: RendererSettingsMessages = Object.freeze({
otherSection: "其他",
appearanceDescription: "会话显示与本地资源管理。",
appearanceGroup: "外观",
+ hideCodexUsageBannerTitle: "隐藏 Codex 额度警告",
+ hideCodexUsageBannerDescription: "默认开启。在所有 Composer 上隐藏额度耗尽卡片。",
+ hideCodexUsageBannerHelpLabel: "隐藏 Codex 额度警告说明",
+ hideCodexUsageBannerHelp: Object.freeze([
+ "已登录的 ChatGPT Codex 额度耗尽时,Desktop 会显示原生警告卡片。",
+ "开启后,所有 Composer 都隐藏该卡片,包括 Codex 会话。「重置用量」和「升级」仍可在设置中使用。",
+ "关闭此开关只会重新显示原生卡片。当前 Composer 的发送(包括 Codex 会话)已与该额度卡片分离。",
+ ]),
cpaLiveVoiceTitle: "改写实时语音",
cpaLiveVoiceDescription: "默认开启。由 Host 创建 realtime 通话,而不是 ChatGPT。",
cpaLiveVoiceHelpLabel: "改写实时语音说明",
diff --git a/packages/renderer-extension/test/renderer-binding-probe.test.ts b/packages/renderer-extension/test/renderer-binding-probe.test.ts
index b636b8519..a34752a33 100644
--- a/packages/renderer-extension/test/renderer-binding-probe.test.ts
+++ b/packages/renderer-extension/test/renderer-binding-probe.test.ts
@@ -42,7 +42,9 @@ import {
isComposerVoiceButton,
creditsPlacementAnchor,
isNativeContextUsageControlCandidate,
+ isNativeUsageBannerCandidate,
nativeContextUsageControlForComposer,
+ nativeUsageBannerForComposer,
reconcileComposerNativeControls,
refreshSendButton,
trailingActionAnchor,
@@ -840,6 +842,152 @@ describe("Renderer Composer DOM behavior", () => {
expect(isNativeContextUsageControlCandidate(credits)).toBe(false);
});
+ it("recognizes only a unique Codex usage recovery aside", () => {
+ const banner = {
+ localName: "aside",
+ getAttribute: (name: string) =>
+ name === "role" ? "status" : name === "aria-live" ? "polite" : null,
+ querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
+ } as unknown as HTMLElement;
+ const unmarked = {
+ localName: "aside",
+ getAttribute: () => null,
+ querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
+ } as unknown as HTMLElement;
+ const notice = {
+ localName: "aside",
+ getAttribute: (name: string) =>
+ name === "role" ? "status" : name === "aria-live" ? "polite" : null,
+ querySelector: () => null,
+ } as unknown as HTMLElement;
+ expect(isNativeUsageBannerCandidate(banner)).toBe(true);
+ expect(isNativeUsageBannerCandidate(unmarked)).toBe(true);
+ expect(isNativeUsageBannerCandidate(notice)).toBe(false);
+ const composer = {
+ querySelectorAll: (selector: string) => (selector === "aside" ? [unmarked] : []),
+ } as unknown as Element;
+ expect(nativeUsageBannerForComposer(composer)).toBe(unmarked);
+ const ambiguous = {
+ querySelectorAll: () => [banner, unmarked],
+ } as unknown as Element;
+ expect(nativeUsageBannerForComposer(ambiguous)).toBeNull();
+ });
+
+ it("hides the Codex usage banner on any Composer by default, including Codex threads", () => {
+ vi.stubGlobal("document", { activeElement: null });
+ const banner = {
+ localName: "aside",
+ hidden: false,
+ getAttribute: (name: string) => {
+ if (name === "role") return "status";
+ if (name === "aria-live") return "polite";
+ if (name === "aria-hidden") return banner.ariaHidden;
+ if (name === "aria-expanded") return null;
+ return null;
+ },
+ setAttribute: (name: string, value: string) => {
+ if (name === "aria-hidden") banner.ariaHidden = value;
+ },
+ removeAttribute: (name: string) => {
+ if (name === "aria-hidden") banner.ariaHidden = null;
+ },
+ querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
+ contains: () => false,
+ click: vi.fn(),
+ ariaHidden: null as string | null,
+ };
+ const control = {
+ composer: {
+ querySelectorAll: (selector: string) => (selector === "aside" ? [banner] : []),
+ },
+ modelPicker: { root: { parentElement: null }, trigger: {} },
+ nativeModelControl: null,
+ nativePermissionModeControl: null,
+ nativeContextUsageControl: null,
+ nativeUsageBanner: null,
+ credits: {
+ anchor: null,
+ place: vi.fn(),
+ root: { remove: vi.fn() },
+ },
+ usage: {
+ anchor: null,
+ place: vi.fn(),
+ root: { remove: vi.fn() },
+ },
+ } as unknown as ComposerAgentControl;
+
+ reconcileComposerNativeControls(control, true, true);
+ expect(banner.hidden).toBe(true);
+ expect(banner.ariaHidden).toBe("true");
+ expect(control.nativeUsageBanner?.element).toBe(banner);
+
+ // The aside reports the account-wide ChatGPT rate limit and upsell, so a
+ // Codex Thread Composer hides it under the same preference.
+ reconcileComposerNativeControls(control, false, false);
+ expect(banner.hidden).toBe(true);
+ expect(banner.ariaHidden).toBe("true");
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps the Codex usage banner when the Settings preference is off", () => {
+ vi.stubGlobal("document", { activeElement: null, defaultView: null });
+ const owner = Object.assign(new EventTarget(), {
+ localStorage: { getItem: () => "0", setItem: vi.fn() },
+ });
+ const banner = {
+ localName: "aside",
+ hidden: false,
+ getAttribute: (name: string) => {
+ if (name === "role") return "status";
+ if (name === "aria-live") return "polite";
+ if (name === "aria-hidden") return banner.ariaHidden;
+ if (name === "aria-expanded") return null;
+ return null;
+ },
+ setAttribute: (name: string, value: string) => {
+ if (name === "aria-hidden") banner.ariaHidden = value;
+ },
+ removeAttribute: (name: string) => {
+ if (name === "aria-hidden") banner.ariaHidden = null;
+ },
+ querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
+ contains: () => false,
+ click: vi.fn(),
+ ariaHidden: null as string | null,
+ };
+ const control = {
+ composer: {
+ ownerDocument: { defaultView: owner },
+ querySelectorAll: (selector: string) => (selector === "aside" ? [banner] : []),
+ },
+ modelPicker: { root: { parentElement: null }, trigger: {} },
+ nativeModelControl: null,
+ nativePermissionModeControl: null,
+ nativeContextUsageControl: null,
+ nativeUsageBanner: null,
+ credits: {
+ anchor: null,
+ place: vi.fn(),
+ root: { remove: vi.fn() },
+ },
+ usage: {
+ anchor: null,
+ place: vi.fn(),
+ root: { remove: vi.fn() },
+ },
+ } as unknown as ComposerAgentControl;
+
+ reconcileComposerNativeControls(control, true, true);
+ expect(banner.hidden).toBe(false);
+ expect(banner.ariaHidden).toBeNull();
+
+ reconcileComposerNativeControls(control, false, false);
+ expect(banner.hidden).toBe(false);
+ expect(banner.ariaHidden).toBeNull();
+ vi.unstubAllGlobals();
+ });
+
it("does not treat attachment controls as submission", () => {
const button = (label: string, type = "button") =>
({
diff --git a/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts b/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
new file mode 100644
index 000000000..6050d7c29
--- /dev/null
+++ b/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ CODEX_USAGE_BANNER_CHANGE_EVENT,
+ CODEX_USAGE_BANNER_STORAGE_KEY,
+ readCodexUsageBannerPreference,
+ writeCodexUsageBannerPreference,
+} from "../src/renderer-codex-usage-banner-preference.js";
+
+function fixture(initial?: string) {
+ const values = new Map();
+ if (initial !== undefined) values.set(CODEX_USAGE_BANNER_STORAGE_KEY, initial);
+ const storage = {
+ getItem: vi.fn((key: string) => values.get(key) ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ values.set(key, value);
+ }),
+ };
+ const owner = Object.assign(new EventTarget(), { localStorage: storage }) as unknown as Window;
+ return { owner, values };
+}
+
+describe("Codex usage banner Settings preference", () => {
+ it("defaults on, persists off as 0, and stays on without a Window", () => {
+ const unset = fixture();
+ expect(readCodexUsageBannerPreference(unset.owner)).toBe(true);
+ expect(readCodexUsageBannerPreference(null)).toBe(true);
+ expect(readCodexUsageBannerPreference(undefined)).toBe(true);
+
+ const events: string[] = [];
+ unset.owner.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, () => events.push("changed"));
+ expect(writeCodexUsageBannerPreference(unset.owner, false)).toBe(true);
+ expect(unset.values.get(CODEX_USAGE_BANNER_STORAGE_KEY)).toBe("0");
+ expect(readCodexUsageBannerPreference(unset.owner)).toBe(false);
+ expect(events).toEqual(["changed"]);
+
+ expect(writeCodexUsageBannerPreference(unset.owner, true)).toBe(true);
+ expect(unset.values.get(CODEX_USAGE_BANNER_STORAGE_KEY)).toBe("1");
+ expect(readCodexUsageBannerPreference(unset.owner)).toBe(true);
+ });
+
+ it("stays on when localStorage throws", () => {
+ const owner = Object.assign(new EventTarget(), {
+ localStorage: {
+ getItem: () => {
+ throw new Error("blocked");
+ },
+ setItem: () => {
+ throw new Error("blocked");
+ },
+ },
+ }) as unknown as Window;
+ expect(readCodexUsageBannerPreference(owner)).toBe(true);
+ expect(writeCodexUsageBannerPreference(owner, false)).toBe(false);
+ });
+});
diff --git a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts
index aa6c371fe..1b039647c 100644
--- a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts
+++ b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts
@@ -12,18 +12,21 @@ function required(value: T | undefined): T {
}
/** Desktop-shaped selectors: an Account rate-limit gate and a reserve gate. */
-function desktopStore() {
+function desktopStore(options: { accountReadsActive?: boolean } = {}) {
let authMethod = "chatgpt";
let allowed = false;
let hardBlocked = false;
+ let active = false;
const auth: Atom = { read: () => ({ authMethod, authenticatedAccountId: "account" }) };
const usage: Atom = { read: () => ({ data: { rate_limit: { allowed } } }) };
- const reserve: Atom = { read: () => ({ active: false, eligible: true, hardBlocked }) };
+ const reserve: Atom = { read: () => ({ active, eligible: true, hardBlocked }) };
const accountGate: Atom = {
read: (get) => {
const a = get(auth) as { authMethod: string };
// Like Desktop: API-key sign-in returns before reading ChatGPT usage.
if (a.authMethod !== "chatgpt") return false;
+ // 26.1007: local reserve-active is an exemption inside the account selector.
+ if (options.accountReadsActive && (get(reserve) as { active: boolean }).active) return false;
return (
(get(usage) as { data: { rate_limit: { allowed: boolean } } }).data.rate_limit.allowed ===
false
@@ -71,6 +74,10 @@ function desktopStore() {
hardBlocked = value;
emit();
},
+ setActive(value: boolean) {
+ active = value;
+ emit();
+ },
listenerCount: () => [...listeners.values()].reduce((n, set) => n + set.size, 0),
};
}
@@ -81,19 +88,23 @@ function composerFixture(
options: {
omitReserve?: boolean;
duplicateAccount?: boolean;
+ duplicateAccountAtom?: boolean;
snapshotWrapper?: boolean;
signalReserve?: boolean;
+ descendantGates?: boolean;
} = {},
) {
const subscribers: Array<{ getSnapshot(): unknown }> = [];
const instances: Array<{ value: unknown; getSnapshot(): unknown }> = [];
const hooks: Array> = [];
const rerender = vi.fn();
+ const extraAccount: Atom = { read: (get) => source.accountGate.read(get) };
for (const selector of [
source.reserveActive,
source.accountGate,
...(options.omitReserve ? [] : [source.reserveGate]),
...(options.duplicateAccount ? [source.accountGate] : []),
+ ...(options.duplicateAccountAtom ? [extraAccount] : []),
]) {
// Desktop 26.928 wraps parameterized selectors in a readonly signal atom.
const signal = options.signalReserve && selector !== source.accountGate;
@@ -138,10 +149,23 @@ function composerFixture(
effect.create();
}
// Desktop owners have more than a thousand hooks; do not rely on positions.
- hooks.unshift(...Array.from({ length: 230 }, () => ({ memoizedState: null })));
- hooks.forEach((hook, i) => {
- hook.next = hooks[i + 1] ?? null;
- });
+ const fillers: Array> = Array.from({ length: 230 }, () => ({
+ memoizedState: null,
+ }));
+ const gateHooks = [...hooks];
+ if (options.descendantGates) {
+ gateHooks.forEach((hook, i) => {
+ hook.next = gateHooks[i + 1] ?? null;
+ });
+ fillers.forEach((hook, i) => {
+ hook.next = fillers[i + 1] ?? null;
+ });
+ } else {
+ hooks.unshift(...fillers);
+ hooks.forEach((hook, i) => {
+ hook.next = hooks[i + 1] ?? null;
+ });
+ }
const props = { onLocalSubmitStart() {}, submitDisabled: false };
// Desktop wraps the owner in a pass-through component with the same props.
const wrapper = {
@@ -149,11 +173,22 @@ function composerFixture(
memoizedState: { memoizedState: null, next: null },
return: null,
};
- const owner = {
+ const owner: Record = {
memoizedProps: props,
- memoizedState: hooks[0] as unknown,
+ memoizedState: (options.descendantGates ? fillers[0] : hooks[0]) as unknown,
return: wrapper,
};
+ if (options.descendantGates) {
+ // Newer Desktop versions subscribe the gates in the component that
+ // receives `submitDisabled` below the owner, not on the owner itself.
+ owner.child = {
+ memoizedProps: { submitDisabled: false },
+ memoizedState: gateHooks[0] as unknown,
+ child: null,
+ sibling: null,
+ return: owner,
+ };
+ }
const editor = { parentElement: null, __reactFiber$test: owner };
const composer = {
isConnected: true,
@@ -197,6 +232,25 @@ describe("Codex usage gate for external Harness Composers", () => {
expect(f.blocked()).toBe(true);
});
+ it("binds gate subscriptions a descendant holds below the submit owner", () => {
+ const f = composerFixture(undefined, { descendantGates: true });
+ expect(inspectComposerCodexUsageGate(f.composer)).toEqual({
+ ownerCount: 1,
+ reserveGateCount: 1,
+ accountGateCount: 1,
+ });
+ expect(f.blocked()).toBe(true);
+
+ expect(f.gate.update(true)).toBe("bypassed");
+ expect(f.blocked()).toBe(false);
+ expect(f.rerender).toHaveBeenCalled();
+ expect(f.gate.refresh()).toBe("bypassed");
+ expect(f.source.store.set).not.toHaveBeenCalled();
+
+ expect(f.gate.update(false)).toBe("native");
+ expect(f.blocked()).toBe(true);
+ });
+
it("binds 26.928 signal selectors and lazy snapshot wrappers", () => {
const source = desktopStore();
source.setHardBlocked(true);
@@ -393,9 +447,11 @@ describe("Codex usage gate for external Harness Composers", () => {
it("keeps native restrictions when the contract is missing, ambiguous or read-only", () => {
for (const options of [
{ omitReserve: true },
- { duplicateAccount: true },
+ { duplicateAccountAtom: true },
{ omitReserve: true, snapshotWrapper: true, signalReserve: true },
- { duplicateAccount: true, snapshotWrapper: true, signalReserve: true },
+ { duplicateAccountAtom: true, snapshotWrapper: true, signalReserve: true },
+ { omitReserve: true, descendantGates: true },
+ { duplicateAccountAtom: true, descendantGates: true },
]) {
const f = composerFixture(undefined, options);
expect(f.gate.update(true)).toBe("unsupported");
@@ -407,6 +463,46 @@ describe("Codex usage gate for external Harness Composers", () => {
expect(f.blocked()).toBe(true);
});
+ it("accepts an account gate with a local reserve-active exemption", () => {
+ const source = desktopStore({ accountReadsActive: true });
+ const f = composerFixture(source);
+ expect(inspectComposerCodexUsageGate(f.composer)).toEqual({
+ ownerCount: 1,
+ reserveGateCount: 1,
+ accountGateCount: 1,
+ });
+ expect(f.blocked()).toBe(true);
+ expect(f.gate.update(true)).toBe("bypassed");
+ expect(f.blocked()).toBe(false);
+ expect(required(f.instances[0]).getSnapshot()).toBe(false);
+ f.gate.update(false);
+ expect(f.blocked()).toBe(true);
+ source.setActive(true);
+ expect(required(f.instances[1]).getSnapshot()).toBe(false);
+ });
+
+ it("projects same-atom account subscriptions on the owner and a descendant", () => {
+ const f = composerFixture(undefined, { duplicateAccount: true, descendantGates: true });
+ expect(inspectComposerCodexUsageGate(f.composer).accountGateCount).toBe(2);
+ expect(f.blocked()).toBe(true);
+ expect(f.gate.update(true)).toBe("bypassed");
+ expect(f.blocked()).toBe(false);
+ expect(f.gate.refresh()).toBe("bypassed");
+ f.gate.dispose();
+ expect(f.blocked()).toBe(true);
+ });
+
+ it("rejects a duplicate account wrapper and restores every projected member", () => {
+ const f = composerFixture(undefined, { duplicateAccount: true, snapshotWrapper: true });
+ Object.assign(required(f.subscribers[3]), {
+ createRender: () => ({ getSnapshot: () => true }),
+ });
+ const original = required(f.instances[1]).getSnapshot;
+ expect(f.gate.update(true)).toBe("unsupported");
+ expect(required(f.instances[1]).getSnapshot).toBe(original);
+ expect(f.blocked()).toBe(true);
+ });
+
it("counts owner and gates for the contract audit without lifting them", () => {
const f = composerFixture();
const sub = f.source.store.sub;
diff --git a/packages/renderer-extension/test/settings/localization.test.ts b/packages/renderer-extension/test/settings/localization.test.ts
index 3ad04d3b3..006ccc5b6 100644
--- a/packages/renderer-extension/test/settings/localization.test.ts
+++ b/packages/renderer-extension/test/settings/localization.test.ts
@@ -33,6 +33,10 @@ describe("Renderer settings localization", () => {
expect(english.title).toBe("Settings");
expect(chinese.title).toBe("设置");
+ expect(english.hideCodexUsageBannerTitle).toBe("Hide Codex usage warning");
+ expect(chinese.hideCodexUsageBannerTitle).toBe("隐藏 Codex 额度警告");
+ expect(english.hideCodexUsageBannerDescription).toContain("On by default");
+ expect(chinese.hideCodexUsageBannerDescription).toContain("默认开启");
expect(english.cpaLiveVoiceTitle).toBe("Override live voice");
expect(chinese.cpaLiveVoiceTitle).toBe("改写实时语音");
expect(english.cpaLiveVoiceTitle.toLowerCase()).not.toContain("cpa");
diff --git a/tools/codex-desktop-contract-audit/README.md b/tools/codex-desktop-contract-audit/README.md
index 368060c07..ed91c2ef6 100644
--- a/tools/codex-desktop-contract-audit/README.md
+++ b/tools/codex-desktop-contract-audit/README.md
@@ -38,11 +38,11 @@ The `codex-usage-gate` surface covers `renderer-codex-usage-gate.ts`, which lets
- `ownerCount` — Composer owners exposing `onLocalSubmitStart` and boolean `submitDisabled` that hold the reserve gate (pass-through wrappers with the same props are excluded);
- `reserveGateCount` — boolean subscriptions whose selector reads reserve `hardBlocked`;
-- `accountGateCount` — boolean subscriptions whose selector reads `authMethod` and `rate_limit.allowed`.
+- `accountGateCount` — boolean subscriptions whose selector reads `authMethod` and `rate_limit.allowed`. One logical gate may appear as several same-atom subscriptions.
Verdicts:
-- an owner or reserve count other than one per Composer, or more than one Account gate, is `confirmed-impact`;
+- owner count other than one per Composer, or fewer reserve gates than Composers, is `confirmed-impact`;
- no Account gate is `unverified` (`codex-usage-account-gate-dormant`): the selector returns early for API-key sign-in or while Account data loads, so inspect again signed in with ChatGPT;
- no Composer with an editor is `unverified`.
diff --git a/tools/codex-desktop-contract-audit/report.mjs b/tools/codex-desktop-contract-audit/report.mjs
index 10666df63..f7d7ec034 100644
--- a/tools/codex-desktop-contract-audit/report.mjs
+++ b/tools/codex-desktop-contract-audit/report.mjs
@@ -404,16 +404,15 @@ export function buildSurfaceResults(contracts, baseline = null, controlled = nul
baseline,
});
// External Harness submission while ChatGPT Codex usage is exhausted depends
- // on one Composer owner exposing one reserve gate and, when it reads usage,
- // one Account gate (renderer-codex-usage-gate.ts). The Account gate returns
- // early for API-key sign-in or loading, so its absence is unverified.
+ // on one Composer owner exposing the reserve gate and, when it reads usage,
+ // the Account gate (renderer-codex-usage-gate.ts). One logical gate may have
+ // several same-atom subscriptions. The Account gate returns early for API-key
+ // sign-in or loading, so its absence is unverified.
const gate = contracts.codexUsageGate;
const gateActive = gate.composerCount > 0;
const gateStructure =
- gate.ownerCount === gate.composerCount &&
- gate.reserveGateCount === gate.composerCount &&
- gate.accountGateCount <= gate.composerCount;
- const accountGateObserved = gate.accountGateCount === gate.composerCount;
+ gate.ownerCount === gate.composerCount && gate.reserveGateCount >= gate.composerCount;
+ const accountGateObserved = gate.accountGateCount >= gate.composerCount;
const codexUsageGate = classifySurface({
id: "codex-usage-gate",
observed: gate,
diff --git a/tools/codex-desktop-contract-audit/report.test.mjs b/tools/codex-desktop-contract-audit/report.test.mjs
index a9168db15..355761b78 100644
--- a/tools/codex-desktop-contract-audit/report.test.mjs
+++ b/tools/codex-desktop-contract-audit/report.test.mjs
@@ -76,6 +76,7 @@ describe("Codex Desktop contract audit report", () => {
verdict: "unverified",
reason: "codex-usage-account-gate-dormant",
});
+ expect(verdictFor({ accountGateCount: 2 })?.verdict).toBe("no-impact");
expect(verdictFor({ composerCount: 0, ownerCount: 0, reserveGateCount: 0 })?.verdict).toBe(
"unverified",
);
From 8575adffd2b97da971d76cb4f273bc1280c5a1aa Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sat, 17 Oct 2026 17:04:19 +0900
Subject: [PATCH 07/98] chore: prepare v0.13.2-3
---
Cargo.lock | 10 +++++-----
Cargo.toml | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 95027f300..b284b1a40 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "codexhost-gate-a-native"
-version = "0.13.2-2"
+version = "0.13.2-3"
dependencies = [
"codexhost-platform",
"codexhost-shim",
@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "codexhost-launcher"
-version = "0.13.2-2"
+version = "0.13.2-3"
dependencies = [
"codexhost-platform",
"fs2",
@@ -106,7 +106,7 @@ dependencies = [
[[package]]
name = "codexhost-platform"
-version = "0.13.2-2"
+version = "0.13.2-3"
dependencies = [
"libproc",
"nix",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "codexhost-shim"
-version = "0.13.2-2"
+version = "0.13.2-3"
dependencies = [
"codexhost-platform",
"fs2",
@@ -131,7 +131,7 @@ dependencies = [
[[package]]
name = "codexhost-updater"
-version = "0.13.2-2"
+version = "0.13.2-3"
dependencies = [
"codexhost-platform",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index cc5c6bfa6..10690661b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ default-members = [
resolver = "3"
[workspace.package]
-version = "0.13.2-2"
+version = "0.13.2-3"
edition = "2024"
rust-version = "1.97.1"
publish = false
diff --git a/package-lock.json b/package-lock.json
index d3e0730ec..006dcc2d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexhost",
- "version": "0.13.2-2",
+ "version": "0.13.2-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexhost",
- "version": "0.13.2-2",
+ "version": "0.13.2-3",
"license": "LGPL-3.0-only",
"workspaces": [
"packages/*",
diff --git a/package.json b/package.json
index 1d00adf4c..e60bed9b8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexhost",
- "version": "0.13.2-2",
+ "version": "0.13.2-3",
"private": true,
"license": "LGPL-3.0-only",
"type": "module",
From c9da99ad81351a377ba640c39f85b13dda412bf5 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:47:03 +0900
Subject: [PATCH 08/98] Revert "feat: hide Codex usage warning and lift the
usage send gate"
This reverts commit ff7fe5b0047b91fa964ed742b1c16443171f2a02.
---
.../codexhost-update-impact-audit/SKILL.md | 8 +-
docs/index.md | 2 +-
...odex-desktop-upgrade-diagnosis-playbook.md | 13 +-
docs/operations/codexhost-console.md | 2 +-
docs/product/codex-accounts.md | 4 +-
.../src/renderer-binding-probe.ts | 25 +--
.../renderer-codex-usage-banner-preference.ts | 22 ---
.../src/renderer-codex-usage-gate.ts | 139 +++++-----------
.../src/renderer-composer-dom.ts | 51 +-----
.../src/settings/appearance-page.ts | 32 ----
.../src/settings/localization.ts | 20 ---
.../test/renderer-binding-probe.test.ts | 148 ------------------
...erer-codex-usage-banner-preference.test.ts | 56 -------
.../test/renderer-codex-usage-gate.test.ts | 116 ++------------
.../test/settings/localization.test.ts | 4 -
tools/codex-desktop-contract-audit/README.md | 4 +-
tools/codex-desktop-contract-audit/report.mjs | 13 +-
.../report.test.mjs | 1 -
18 files changed, 75 insertions(+), 585 deletions(-)
delete mode 100644 packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
delete mode 100644 packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
diff --git a/.agents/skills/codexhost-update-impact-audit/SKILL.md b/.agents/skills/codexhost-update-impact-audit/SKILL.md
index 8e502269a..8ac006b90 100644
--- a/.agents/skills/codexhost-update-impact-audit/SKILL.md
+++ b/.agents/skills/codexhost-update-impact-audit/SKILL.md
@@ -128,15 +128,15 @@ The runner does not accept the `.app` directory. A controlled flow may verify Ag
### Codex usage gate probe
-This surface lets an external Harness submit while the ChatGPT-signed-in Codex subscription is out of usage by projecting `false` for one Composer's verified Desktop usage-gate subscriptions. It depends on private React/state-library internals, so check it on every update. `npm run audit:codex-desktop` reports its structure as the `codex-usage-gate` surface (see `tools/codex-desktop-contract-audit/README.md`); `codex-usage-account-gate-dormant` means the audit ran without a ChatGPT sign-in whose Account gate reads usage. The audit does not verify behavior. Raw subscription counts may exceed one per logical gate when the same store+atom is subscribed on the owner and a descendant.
+This surface lets an external Harness submit while the ChatGPT-signed-in Codex subscription is out of usage by projecting `false` for one Composer's two Desktop usage-gate subscriptions. It depends on private React/state-library internals, so check it on every update. `npm run audit:codex-desktop` reports its structure as the `codex-usage-gate` surface (see `tools/codex-desktop-contract-audit/README.md`); `codex-usage-account-gate-dormant` means the audit ran without a ChatGPT sign-in whose Account gate reads usage. The audit does not verify behavior.
-Bundle evidence (evidence only, not contracts): the Composer owner combines `submitDisabled` from an Account rate-limit selector (`authMethod !== "chatgpt"` early return, then `rate_limit.allowed === false`, with a local `reserve.active` exemption, gated to host `local`) and a reserve `hardBlocked` selector. Confirm both still exist and still feed `submitDisabled`, and that no new Account-wide usage blocker was added beside them. If Desktop now exempts external models/Threads or stops blocking in the Renderer, report that the module can be removed.
+Bundle evidence (evidence only, not contracts): the Composer owner combines `submitDisabled` from an Account rate-limit selector (`authMethod !== "chatgpt"` early return, then `rate_limit.allowed === false`, gated to host `local`) and a reserve `hardBlocked` selector. Confirm both still exist and still feed `submitDisabled`, and that no new Account-wide usage blocker was added beside them. If Desktop now exempts external models/Threads or stops blocking in the Renderer, report that the module can be removed.
Manual read-only live probe, when the audit surface is not `no-impact` or its counts need explaining, in a Composer inside the primary Renderer. Do not patch `getSnapshot`, subscribe to the store, or call effect functions:
1. From the editor's nearest React host, walk committed ancestors; exactly one owner has `onLocalSubmitStart` and boolean `submitDisabled`, in both a new draft and an existing Thread.
-2. In that owner and its descendants, find subscriber memo candidates with dependencies `[store, atom]` or Desktop 26.928's `[signal, undefined]`, where the readonly signal exposes `atom`, `store`, `get`, and `subscribe`. The next hook has `queue = { value: boolean, getSnapshot }`, followed by effect `{ create, deps: [subscriber.subscribe] }`, without `atom.write`, and `createRender()` returning nothing. An instance getter may equal the subscriber getter or be a lazy wrapper; for an unbound wrapper, both snapshots must equal the native boolean `store.get(atom)`.
-3. Replay each candidate's `atom.read` with tracing proxies, following readonly boolean selector indirections with cycle and work bounds. Exactly one logical reserve group reads `hardBlocked` without `active` or Account gate fields; every member shares store and atom. When signed in with ChatGPT, the Account group reads `authMethod` and `rate_limit.allowed`; it may also read `reserve.active` as a local exemption. Reject any member that also reads `hardBlocked`, and reject mixed store/atom identities within a kind. Each replayed boolean must equal its native value.
+2. In its hook list, find subscriber memo candidates with dependencies `[store, atom]` or Desktop 26.928's `[signal, undefined]`, where the readonly signal exposes `atom`, `store`, `get`, and `subscribe`. The next hook has `queue = { value: boolean, getSnapshot }`, followed by effect `{ create, deps: [subscriber.subscribe] }`, without `atom.write`, and `createRender()` returning nothing. An instance getter may equal the subscriber getter or be a lazy wrapper; for an unbound wrapper, both snapshots must equal the native boolean `store.get(atom)`.
+3. Replay each candidate's `atom.read` with tracing proxies, following readonly boolean selector indirections with cycle and work bounds. Exactly one reads `hardBlocked` without `active` or Account gate fields (reserve). When signed in with ChatGPT, exactly one reads `authMethod` and `rate_limit.allowed` without reserve fields (Account). Each replayed boolean must equal its native value; reject mixed gates.
4. `store.sub`, `subscriber.getSnapshot`, and the instance `getSnapshot` are writable.
With codexhost running and an external Agent selected, bound gates show an instance `getSnapshot` of `() => false` for both hooks. The Agent control's hover title shows the "cannot separate from Codex usage" message when binding failed.
diff --git a/docs/index.md b/docs/index.md
index 65bd02011..a114aebf6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -100,7 +100,7 @@
| 文档 | 内容与阅读时机 |
| --- | --- |
-| [`product/codex-accounts.md`](product/codex-accounts.md) | 设置页中的原生账号与额度布局、外部 Harness 不受 Codex 额度门限制、外观页「隐藏 Codex 额度警告」(默认开启)、手动导入 Pi 及导入记录管理;修改账号 UI、发送额度门、额度横幅或凭证导入链路时阅读。 |
+| [`product/codex-accounts.md`](product/codex-accounts.md) | 设置页中的原生账号与额度布局、外部 Harness 不受 Codex 额度门限制、手动导入 Pi 及导入记录管理;修改账号 UI、发送额度门、查询或凭证导入链路时阅读。 |
| [`product/usage-metering.md`](product/usage-metering.md) | 外部 Harness 与官方 Codex 的会话用量、插件可选能力、实时刷新、费用、缓存命中、首字延迟及速度限制;修改计量、价格表或 `pricing.json` 时阅读。 |
| [`product/usage-statistics.md`](product/usage-statistics.md) | 控制台与 Desktop 内置设置的全局用量统计:独立 Codex 统计插件、各 Harness 原生会话去重、按模型 ID 读时计价与缓存;新增读取器或修改统计口径时阅读。 |
| [`product/codex-native-account-switching-design.md`](product/codex-native-account-switching-design.md) | 移除 Codex 多账号切换后的只读边界;修改 Codex 认证或账号路由时阅读。 |
diff --git a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
index 55be5d33f..30ade5e67 100644
--- a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
+++ b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
@@ -328,23 +328,22 @@ grok
### 检查 Codex 额度门
-ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 disabled,且 Agent 控件悬停提示「无法将此 Harness 与 Codex 额度限制分离」,说明 `renderer-codex-usage-gate.ts` 没有绑定成功。API Key 登录不会触发这道门,不能用来复现。额度耗尽横幅是否显示由设置 → 外观「隐藏 Codex 额度警告」控制(默认开启,作用于所有 Composer,包括 Codex 会话);横幅隐藏成功不能代替这道发送门的绑定检查。
+ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 disabled,且 Agent 控件悬停提示「无法将此 Harness 与 Codex 额度限制分离」,说明 `renderer-codex-usage-gate.ts` 没有绑定成功。API Key 登录不会触发这道门,不能用来复现。
在主 Renderer 中只读检查(不修改 getSnapshot,不订阅 store):
```text
1. 从编辑器向上找到唯一带 onLocalSubmitStart 和布尔 submitDisabled 的组件
-2. 在该组件及其下级子树的 hook 链中找「subscriber memo → useSyncExternalStore inst
- → 订阅 effect」三连(新版本 Desktop 把门订阅在接收 submitDisabled 的下级组件上):
+2. 在其 hook 链中找「subscriber memo → useSyncExternalStore inst → 订阅 effect」三连:
- memo 依赖为 [store, atom],或 26.928 的 [readonly signal adapter, undefined]
- adapter 提供 atom/store/get/subscribe;atom 不得可写;effect 依赖 subscriber.subscribe
- inst.getSnapshot 可以是 subscriber.getSnapshot,也可以是 lazy createRender wrapper;
wrapper 的布尔快照必须与 subscriber 和原生 atom 相同,且不能产生追踪型 render
3. 对每个布尔候选,用追踪代理重放 atom.read;26.928 的 signal 会间接读取 readonly
布尔 selector,需有界递归重放,拒绝循环、结果不一致和混合门:
- - 读取 hardBlocked 但不读取 active、账号门字段:reserve 门,同一 store+atom 可有多份订阅
- - 读取 authMethod 且读取 rate_limit.allowed:账号门;可以读取 reserve.active 作为本机豁免,
- 不可再读取 hardBlocked。同一 store+atom 可有多份订阅(提交组件与下级各一份)
+ - 读取 hardBlocked 但不读取 active、账号门字段:reserve 门,必须恰好 1 个
+ - 读取 authMethod 且读取 rate_limit.allowed,不读取 reserve 字段:账号门,
+ 额度耗尽时必须恰好 1 个
```
任一步骤数量不对,按 Desktop 新结构修改识别条件;不要改为按 hook 序号、压缩名或写入账号数据放行。若 Desktop 改为按 Thread、Model 或 host 豁免外部 Harness,或不再在 Renderer 中拦截,删除该模块。
@@ -455,7 +454,7 @@ ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 dis
- [ ] 点击 DeepSeek Harness 后 Model catalog 可加载
- [ ] 点击 Grok 后 Model catalog 可加载
- [ ] 切回 Codex 后原生 Composer 仍可用
-- [ ] ChatGPT 账号 Codex 额度耗尽时,新 Thread、已有外部 Thread 和 Codex Thread 的发送按钮均可点击(空输入等其他原生限制仍生效)
+- [ ] ChatGPT 账号 Codex 额度耗尽时,新 Thread 和已有外部 Thread 均可发送;切回 Codex 后发送按钮恢复 disabled
- [ ] 新 Thread 和未锁定 Thread 都完成切换验证
- [ ] 已锁定 Thread 仍按设计保持 locked
- [ ] 真实错误不会只被转换为笼统的 `Models unavailable`
diff --git a/docs/operations/codexhost-console.md b/docs/operations/codexhost-console.md
index 15c226a75..0983044ce 100644
--- a/docs/operations/codexhost-console.md
+++ b/docs/operations/codexhost-console.md
@@ -6,7 +6,7 @@
- **设置**:连接、远程连接、账号、会话导入、更新,与 Codex 设置页使用同一套页面和 Host 接口。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。
- **其他**:关于。
-外观(思考文本显示、隐藏 Codex 额度警告、空闲释放、改写实时语音)由 Codex 界面保存,只能在 Codex 设置页修改。隐藏 Codex 额度警告与改写实时语音均默认开启。`~/.codex/config.toml` 里的 realtime 基址仍需自行配置。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
+外观(思考文本显示、空闲释放、改写实时语音)由 Codex 界面保存,只能在 Codex 设置页修改。本 fork 的改写实时语音默认开启;`~/.codex/config.toml` 里的 realtime 基址仍需自行配置。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
## 项目公告
diff --git a/docs/product/codex-accounts.md b/docs/product/codex-accounts.md
index 2a1976743..8bb003625 100644
--- a/docs/product/codex-accounts.md
+++ b/docs/product/codex-accounts.md
@@ -24,9 +24,9 @@
ChatGPT 登录的 Codex 订阅额度耗尽时,Desktop 在 Renderer 中用两道账号级布尔门禁用 Composer 提交:账号额度门和 reserve `hardBlocked`。API Key 登录不经过这两道门。它们是界面上的订阅额度预检,不是协议限制;外部 Harness 的 `turn/start` 由 Host 路由,不会发到官方后端。
-因此在单个 Composer 没有 codexhost 自身的提交阻塞时(Codex,或外部 Agent 且 Adapter 就绪),`renderer-codex-usage-gate.ts` 只把该 Composer 对这两道门的订阅快照投影为 `false`,继续走原生提交链路。不写账号、atom 或额度查询缓存。设置 → 外观中的「隐藏 Codex 额度警告」默认开启:所有 Composer 上的官方额度耗尽横幅(Composer 内唯一带标题与 CTA 按钮的 `aside`,含 Reset usage / Upgrade;部分 Desktop 版本另带 `role="status"` 与 `aria-live="polite"`)都隐藏,包括 Codex 会话;关闭该开关后横幅恢复原生显示。空输入、附件、运行中等其他原生限制不受影响。官方 Codex 后端仍可能因额度拒绝该 Turn;外部 Harness 的真实额度与错误由其自身处理。Composer 移除或扩展卸载时恢复实时原生结果与横幅。
+因此在单个 Composer 选中外部 Agent、Adapter 就绪且没有 codexhost 自身的提交阻塞时,`renderer-codex-usage-gate.ts` 只把该 Composer 对这两道门的订阅快照投影为 `false`,继续走原生提交链路。不写账号、atom 或额度查询缓存,Codex 额度横幅保持显示;其他 Composer、Codex 路径和空输入、附件、运行中等其他原生限制不受影响。外部 Harness 的真实额度与错误由其自身处理。切回 Codex、Composer 移除或扩展卸载时恢复实时原生结果。
-门按其 selector 实际读取的字段识别,不依赖压缩名或 hook 序号;门可能挂在提交组件自身,也可能挂在接收 `submitDisabled` 的下级组件,两处都扫描。同一 store+atom 的多份订阅视为同一道逻辑门并一并投影;不同 atom 或 store 则保留原生限制。账号门可以读取 `reserve.active` 作为本机豁免;同时读取 `hardBlocked` 的混合选择器仍保留原生限制。兼容直接 `[store, atom]` 订阅及 Desktop 26.928 的 readonly signal adapter / lazy snapshot wrapper:以成对的订阅 effect 和原生快照一致性验证 wrapper,限量重放 readonly 布尔 selector 的依赖以识别间接 reserve 门;拒绝循环依赖、追踪型 render、混合门和快照不匹配。释放时分别恢复 subscriber 与 React instance 原来的 getter,不把 wrapper 替换成另一个函数。无法唯一识别时保留原生限制,并在 Agent 控件悬停提示中说明。升级后的诊断步骤见 [Desktop 更新兼容性诊断手册](../operations/codex-desktop-upgrade-diagnosis-playbook.md#检查-codex-额度门)。
+门按其 selector 实际读取的字段识别,不依赖压缩名或 hook 序号。兼容直接 `[store, atom]` 订阅及 Desktop 26.928 的 readonly signal adapter / lazy snapshot wrapper:以成对的订阅 effect 和原生快照一致性验证 wrapper,限量重放 readonly 布尔 selector 的依赖以识别间接 reserve 门;拒绝循环依赖、追踪型 render、混合门和快照不匹配。释放时分别恢复 subscriber 与 React instance 原来的 getter,不把 wrapper 替换成另一个函数。无法唯一识别时保留原生限制,并在 Agent 控件悬停提示中说明。升级后的诊断步骤见 [Desktop 更新兼容性诊断手册](../operations/codex-desktop-upgrade-diagnosis-playbook.md#检查-codex-额度门)。
## 其他 Harness 的只读账号额度
diff --git a/packages/renderer-extension/src/renderer-binding-probe.ts b/packages/renderer-extension/src/renderer-binding-probe.ts
index 47ad2a6d3..7e311c412 100644
--- a/packages/renderer-extension/src/renderer-binding-probe.ts
+++ b/packages/renderer-extension/src/renderer-binding-probe.ts
@@ -58,10 +58,6 @@ import {
TRANSCRIPT_ITEM_SELECTOR,
} from "./renderer-transcript-dom.js";
import { RendererCodexAccountState } from "./renderer-codex-account-state.js";
-import {
- CODEX_USAGE_BANNER_CHANGE_EVENT,
- CODEX_USAGE_BANNER_STORAGE_KEY,
-} from "./renderer-codex-usage-banner-preference.js";
import {
createRendererCodexUsageGate,
type RendererCodexUsageGate,
@@ -1013,7 +1009,7 @@ export function installRendererBindingProbe(
const currentCodexAccount = accounts?.accounts.find(
({ accountId }) => accountId === accounts.readyAccountId,
);
- const usageGateAllowed = renderComposerAgentControl(
+ const externalSubmissionReady = renderComposerAgentControl(
mounted.control,
controller.get(mounted.composer),
composerAdapterState(mounted),
@@ -1027,7 +1023,7 @@ export function installRendererBindingProbe(
currentCodexAccount ?? null,
mounted.ownershipStatus === "error",
);
- showCodexUsageGateStatus(mounted, mounted.codexUsageGate.update(usageGateAllowed));
+ showCodexUsageGateStatus(mounted, mounted.codexUsageGate.update(externalSubmissionReady));
if (mounted.control.usage) {
mounted.control.usage.onOpen = () => {
void refreshThreadUsage(
@@ -3126,25 +3122,10 @@ export function installRendererBindingProbe(
}
}
};
- const onUsageBannerPreference = (): void => {
- for (const mounted of mountedByComposer.values()) {
- const hideCodexControls =
- controller.isSwitching(mounted.composer) ||
- controller.get(mounted.composer).agent !== "codex";
- reconcileComposerNativeControls(mounted.control, hideCodexControls, hideCodexControls);
- }
- };
- const onUsageBannerStorage = (event: StorageEvent): void => {
- if (event.key === CODEX_USAGE_BANNER_STORAGE_KEY || event.key === null) {
- onUsageBannerPreference();
- }
- };
window.addEventListener("codexhost:draft-prewarm-policy-changed", onHostRouteChange);
window.addEventListener("codexhost:draft-workspace", onDraftWorkspace);
window.addEventListener("codexhost:renderer-adapter-status", onAdapterStatus);
window.addEventListener("focus", onWindowFocus);
- window.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, onUsageBannerPreference);
- window.addEventListener("storage", onUsageBannerStorage);
delegationMention = installRendererDelegationMention(document, {
onOpen: (editor) => {
const composer = composerForElement(editor);
@@ -3338,8 +3319,6 @@ export function installRendererBindingProbe(
window.removeEventListener("codexhost:draft-workspace", onDraftWorkspace);
window.removeEventListener("codexhost:renderer-adapter-status", onAdapterStatus);
window.removeEventListener("focus", onWindowFocus);
- window.removeEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, onUsageBannerPreference);
- window.removeEventListener("storage", onUsageBannerStorage);
for (const state of harnessAvailabilityByHost.values()) {
state.requestGeneration += 1;
state.codexAccounts?.dispose();
diff --git a/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts b/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
deleted file mode 100644
index 340770707..000000000
--- a/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-export const CODEX_USAGE_BANNER_STORAGE_KEY = "codexhost.codex-usage-banner-hidden.v1";
-export const CODEX_USAGE_BANNER_CHANGE_EVENT = "codexhost:codex-usage-banner-changed";
-
-/** Hide the native Codex out-of-usage card on every Composer. Default on. */
-export function readCodexUsageBannerPreference(owner: Window | null | undefined): boolean {
- if (!owner) return true;
- try {
- return owner.localStorage.getItem(CODEX_USAGE_BANNER_STORAGE_KEY) !== "0";
- } catch {
- return true;
- }
-}
-
-export function writeCodexUsageBannerPreference(owner: Window, enabled: boolean): boolean {
- try {
- owner.localStorage.setItem(CODEX_USAGE_BANNER_STORAGE_KEY, enabled ? "1" : "0");
- } catch {
- return false;
- }
- owner.dispatchEvent(new Event(CODEX_USAGE_BANNER_CHANGE_EVENT));
- return true;
-}
diff --git a/packages/renderer-extension/src/renderer-codex-usage-gate.ts b/packages/renderer-extension/src/renderer-codex-usage-gate.ts
index de10d43f0..49c242d59 100644
--- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts
+++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts
@@ -1,14 +1,14 @@
/**
- * Lets a Composer submit while the signed-in ChatGPT Codex subscription is
- * out of usage.
+ * Lets an external Harness Composer submit while the signed-in ChatGPT Codex
+ * subscription is out of usage.
*
* Desktop blocks Composer submission through two boolean selectors: the
* ChatGPT Account rate-limit gate and the reserve `hardBlocked` gate. Both are
* Account-wide, only apply to ChatGPT sign-in, and are not a protocol limit.
* This module projects `false` for those two subscriptions of one Composer
- * only. It never writes Accounts, atoms or query data, so other Composers keep
- * the native result. Other native submit blockers are unaffected. Anything
- * that cannot be verified keeps the native restriction.
+ * only. It never writes Accounts, atoms or query data, so the Codex path and
+ * other Composers keep the native result. Other native submit blockers are
+ * unaffected. Anything that cannot be verified keeps the native restriction.
*/
import { committedReactAncestors } from "@codexhost/desktop-control/renderer-bindings";
@@ -63,12 +63,9 @@ function hooksOf(fiber: unknown): Hook[] {
}
/**
- * Composer components that combine native submit blockers into `submitDisabled`.
- * Desktop wraps the owner in a pass-through component with the same props, so
- * props alone are ambiguous: a candidate whose own hooks hold the reserve gate
- * wins. When no candidate holds gates, they may be subscribed in a descendant;
- * candidates are ordered innermost first, so the first candidate whose subtree
- * holds the gates is the owner.
+ * Composer components that combine native submit blockers into `submitDisabled`
+ * and hold the reserve gate. Desktop wraps the owner in a pass-through component
+ * with the same props but no gate hooks, so props alone are ambiguous.
*/
function submitOwners(composer: Element): Fiber[] {
// The ProseMirror editor is not rendered by React; start at its nearest React host.
@@ -78,7 +75,7 @@ function submitOwners(composer: Element): Fiber[] {
name.startsWith("__reactFiber$"),
);
if (key) {
- const candidates = committedReactAncestors(
+ const owners = committedReactAncestors(
Object.getOwnPropertyDescriptor(element, key)?.value,
).filter(
({ memoizedProps: props }) =>
@@ -86,11 +83,10 @@ function submitOwners(composer: Element): Fiber[] {
"onLocalSubmitStart" in props &&
typeof props.submitDisabled === "boolean",
) as Fiber[];
- const owner = candidates.find((fiber) => {
- const gates = subtreeGateSubscriptions(fiber);
+ return owners.filter((owner) => {
+ const gates = gateSubscriptions(owner);
return !gates.invalid && gates.reserve.length > 0;
});
- return owner ? [owner] : candidates;
}
element = element.parentElement;
}
@@ -207,9 +203,8 @@ function gateKind({ store, atom }: Subscription): GateKind | "mixed" | null {
}
const reserve = read.has("reserve.hardBlocked") && !read.has("reserve.active");
const account = read.has("auth.authMethod") && read.has("limit.allowed");
- // Account may read reserve.active as a local exemption. Mixing in
- // hardBlocked cannot be separated from the reserve gate.
- if (account && read.has("reserve.hardBlocked")) return "mixed";
+ // A combined selector cannot be separated into the two verified gates.
+ if (account && (read.has("reserve.hardBlocked") || read.has("reserve.active"))) return "mixed";
if (reserve) return "reserve";
if (account) return "account";
return null;
@@ -242,41 +237,9 @@ function snapshotsMatch(
}
}
-function projectionFor(
- projections: ReadonlyMap | undefined,
- instance: Instance,
-): Projection | undefined {
- return projections?.get(instance);
-}
-
-/** True when every subscription is the same store+atom pair, including none or one. */
-function sameLogicalGate(subscriptions: readonly Subscription[]): boolean {
- const first = subscriptions[0];
- if (!first) return true;
- return subscriptions.every((item) => item.store === first.store && item.atom === first.atom);
-}
-
-type FiberNode = Fiber & { child?: unknown; sibling?: unknown };
-
-const MAX_SUBTREE_FIBERS = 1024;
-
-/** Committed descendant fibers of a component, bounded to one Composer. */
-function subtreeFibers(root: Fiber): Fiber[] {
- const result: Fiber[] = [];
- const stack: unknown[] = [(root as FiberNode).child];
- while (stack.length > 0) {
- const fiber = stack.pop();
- if (!isRecord(fiber)) continue;
- result.push(fiber as Fiber);
- if (result.length >= MAX_SUBTREE_FIBERS) return [];
- stack.push((fiber as FiberNode).child, (fiber as FiberNode).sibling);
- }
- return result;
-}
-
function gateSubscriptions(
owner: Fiber,
- projections?: ReadonlyMap,
+ projections?: ReadonlyMap,
): Record & { invalid: boolean } {
const found: Record & { invalid: boolean } = {
account: [],
@@ -287,10 +250,7 @@ function gateSubscriptions(
const subscription = subscriptionAt(hook);
const kind = subscription && gateKind(subscription);
if (subscription && kind) {
- if (
- kind !== "mixed" &&
- snapshotsMatch(subscription, projectionFor(projections, subscription.instance))
- ) {
+ if (kind !== "mixed" && snapshotsMatch(subscription, projections?.get(kind))) {
found[kind].push(subscription);
} else found.invalid = true;
}
@@ -299,44 +259,23 @@ function gateSubscriptions(
}
/**
- * Gate subscriptions anywhere under the submit owner, including the component
- * that forwards `submitDisabled`. Several hooks of one kind are one logical
- * gate when they share store and atom; different atoms stay ambiguous.
- */
-function subtreeGateSubscriptions(
- owner: Fiber,
- projections?: ReadonlyMap,
-): Record & { invalid: boolean } {
- const combined: Record & { invalid: boolean } = {
- account: [],
- reserve: [],
- invalid: false,
- };
- for (const fiber of [owner, ...subtreeFibers(owner)]) {
- const gates = gateSubscriptions(fiber, projections);
- if (gates.invalid) combined.invalid = true;
- combined.account.push(...gates.account);
- combined.reserve.push(...gates.reserve);
- }
- if (!sameLogicalGate(combined.account) || !sameLogicalGate(combined.reserve)) {
- combined.invalid = true;
- }
- return combined;
-}
-
-/**
- * Gate subscriptions grouped by kind; `null` when a kind is ambiguous. The
- * account gate returns early without reading usage when it cannot block (for
- * example API-key sign-in), so it may legitimately be absent until it reads
- * `rate_limit`.
+ * Gate subscriptions by kind; `null` when a kind is ambiguous. The account gate
+ * returns early without reading usage when it cannot block (for example API-key
+ * sign-in), so it may legitimately be absent until it reads `rate_limit`.
*/
function discoverGates(
owner: Fiber,
- projections: ReadonlyMap,
-): Record | null {
- const gates = subtreeGateSubscriptions(owner, projections);
+ projections: ReadonlyMap,
+): Map | null {
+ const gates = gateSubscriptions(owner, projections);
if (gates.invalid) return null;
- return { account: gates.account, reserve: gates.reserve };
+ const found = new Map();
+ for (const kind of ["account", "reserve"] as const) {
+ const subscriptions = gates[kind];
+ if (subscriptions.length > 1) return null;
+ if (subscriptions[0]) found.set(kind, subscriptions[0]);
+ }
+ return found;
}
export interface RendererCodexUsageGateInspection {
@@ -348,7 +287,7 @@ export interface RendererCodexUsageGateInspection {
/** Read-only contract audit: counts only, never patches or subscribes. */
export function inspectComposerCodexUsageGate(composer: Element): RendererCodexUsageGateInspection {
const owners = submitOwners(composer);
- const gates = owners.length === 1 && owners[0] ? subtreeGateSubscriptions(owners[0]) : null;
+ const gates = owners.length === 1 && owners[0] ? gateSubscriptions(owners[0]) : null;
return {
ownerCount: owners.length,
reserveGateCount: gates?.reserve.length ?? 0,
@@ -406,11 +345,8 @@ function project({ subscriber, store, atom, instance, subscribeEffect }: Subscri
function isBound(owner: Fiber, projections: Iterable): boolean {
const alternate = isRecord(owner.alternate) ? owner.alternate : null;
const bound = [...projections];
- const subtrees = [owner, alternate].flatMap((fiber) =>
- fiber ? [[fiber, ...subtreeFibers(fiber)]] : [],
- );
- return subtrees.some((fibers) => {
- const hooks = fibers.flatMap((fiber) => hooksOf(fiber));
+ return [owner, alternate].some((fiber) => {
+ const hooks = hooksOf(fiber);
return bound.every(({ subscriber, instance }) =>
hooks.some(
(hook) =>
@@ -425,7 +361,7 @@ function isBound(owner: Fiber, projections: Iterable): boolean {
export type RendererCodexUsageGateStatus = "native" | "bypassed" | "unsupported";
export interface RendererCodexUsageGate {
- /** `allowed`: this Composer should lift the native Codex usage submit gate. */
+ /** `allowed`: this Composer submits to a ready external Harness. */
update(allowed: boolean): RendererCodexUsageGateStatus;
/** Re-apply the last decision, rebinding if Desktop replaced the Composer hooks. */
refresh(): RendererCodexUsageGateStatus;
@@ -434,7 +370,7 @@ export interface RendererCodexUsageGate {
export function createRendererCodexUsageGate(composer: Element): RendererCodexUsageGate {
let owner: Fiber | null = null;
- const projections = new Map();
+ const projections = new Map();
let retryAt = 0;
let allowed = false;
const release = (): void => {
@@ -453,12 +389,10 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs
const current = owner ?? findSubmitOwner(composer);
const gates = current && discoverGates(current, projections);
// The reserve gate always reads its field, so its absence means an unknown contract.
- if (!current || !gates || gates.reserve.length === 0) {
- throw new Error("Codex usage gate is unavailable");
- }
+ if (!current || !gates?.has("reserve")) throw new Error("Codex usage gate is unavailable");
owner = current;
- for (const gate of [...gates.reserve, ...gates.account]) {
- if (!projections.has(gate.instance)) projections.set(gate.instance, project(gate));
+ for (const [kind, gate] of gates) {
+ if (!projections.has(kind)) projections.set(kind, project(gate));
}
return "bypassed";
};
@@ -472,6 +406,7 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs
release();
retryAt = 0;
}
+ if (owner && projections.size === 2) return "bypassed";
if (Date.now() < retryAt) return owner ? "bypassed" : "unsupported";
retryAt = Date.now() + RETRY_DELAY_MS;
try {
diff --git a/packages/renderer-extension/src/renderer-composer-dom.ts b/packages/renderer-extension/src/renderer-composer-dom.ts
index e7555b75c..13e7bd35e 100644
--- a/packages/renderer-extension/src/renderer-composer-dom.ts
+++ b/packages/renderer-extension/src/renderer-composer-dom.ts
@@ -50,7 +50,6 @@ import {
mountRendererHarnessCommandControl,
type RendererHarnessCommandControl,
} from "./renderer-harness-command-control.js";
-import { readCodexUsageBannerPreference } from "./renderer-codex-usage-banner-preference.js";
export { CONTROL_ATTRIBUTE };
export type ExternalModelControlView = RendererModelControlView;
@@ -92,7 +91,6 @@ export interface ComposerAgentControl {
nativeModelControl: NativeModelControlState | null;
nativePermissionModeControl: NativePermissionModeControlState | null;
nativeContextUsageControl?: NativeControlState | null;
- nativeUsageBanner?: NativeControlState | null;
nativePermissionModeControlVerified: boolean;
credits: RendererCreditsControl;
usage: RendererUsageControl | null;
@@ -360,24 +358,6 @@ export function nativeContextUsageControlForComposer(composer: Element): HTMLEle
return candidates.length === 1 ? (candidates[0] ?? null) : null;
}
-/**
- * Codex usage / upsell recovery banner above the Composer: an aside with a
- * title and CTA buttons. Some Desktop versions also mark it as a polite
- * status; others omit those ARIA attributes. Ambiguous matches stay visible.
- */
-export function isNativeUsageBannerCandidate(element: Element): boolean {
- if (element.localName !== "aside") return false;
- if (typeof element.querySelector !== "function") return false;
- return element.querySelector("h3") !== null && element.querySelector("button") !== null;
-}
-
-export function nativeUsageBannerForComposer(composer: Element): HTMLElement | null {
- const candidates = [...composer.querySelectorAll("aside")].filter(
- isNativeUsageBannerCandidate,
- );
- return candidates.length === 1 ? (candidates[0] ?? null) : null;
-}
-
function contractElementVisible(element: Element): boolean {
const typed = element as HTMLElement;
const bounds = typed.getBoundingClientRect?.();
@@ -472,13 +452,6 @@ function refreshNativeContextUsageControl(control: ComposerAgentControl): void {
control.nativeContextUsageControl = captureNativeControl(candidate);
}
-function refreshNativeUsageBanner(control: ComposerAgentControl): void {
- const candidate = nativeUsageBannerForComposer(control.composer);
- if (candidate === control.nativeUsageBanner?.element) return;
- restoreNativeControl(control.nativeUsageBanner);
- control.nativeUsageBanner = captureNativeControl(candidate);
-}
-
function refreshNativeModelControl(control: ComposerAgentControl): void {
const candidate = nativeModelControlForComposer(control.composer);
if (!candidate) return;
@@ -667,7 +640,6 @@ export function reconcileComposerNativeControls(
// it below, so Credits never reads a stale (e.g. mount-time fallback)
// location for it within this same pass.
refreshNativePermissionModeControl(control);
- refreshNativeUsageBanner(control);
refreshTrailingClusterPlacement(control);
refreshUsagePlacement(control);
refreshCreditsPlacement(control);
@@ -677,20 +649,6 @@ export function reconcileComposerNativeControls(
// visible when the external Model control is substituted.
setNativeControlHidden(control.nativeContextUsageControl, false);
setNativeControlHidden(control.nativePermissionModeControl, hidePermissionMode);
- // Settings → Appearance, default on: hide the account-wide Codex usage
- // aside on every Composer, including Codex threads. The aside reports the
- // ChatGPT Account rate limit and upsell, not a per-thread notice.
- setNativeControlHidden(
- control.nativeUsageBanner,
- readCodexUsageBannerPreference(preferenceOwner(control)),
- );
-}
-
-function preferenceOwner(control: ComposerAgentControl): Window | null {
- return (
- control.composer.ownerDocument?.defaultView ??
- (typeof document !== "undefined" ? document.defaultView : null)
- );
}
export function mountComposerAgentControl(
@@ -714,7 +672,6 @@ export function mountComposerAgentControl(
const nativeContextUsageControl = captureNativeControl(
nativeContextUsageControlForComposer(composer),
);
- const nativeUsageBanner = captureNativeControl(nativeUsageBannerForComposer(composer));
const semanticNativePermissionModeControl =
semanticNativePermissionModeControlForComposer(composer);
const nativePermissionModeControl = captureNativeControl(semanticNativePermissionModeControl);
@@ -789,7 +746,6 @@ export function mountComposerAgentControl(
nativeModelControl,
nativePermissionModeControl,
nativeContextUsageControl,
- nativeUsageBanner,
nativePermissionModeControlVerified,
credits,
usage: null,
@@ -815,8 +771,8 @@ function nativeSendDisabled(button: HTMLButtonElement, fallback: boolean): boole
}
/**
- * Returns whether this Composer should lift the native Codex usage submit
- * gate: no codexhost submission blocker, and either Codex or a ready Adapter.
+ * Returns whether this Composer is ready to submit to an external Harness:
+ * not Codex, Adapter ready, and no codexhost submission blocker.
*/
export function renderComposerAgentControl(
control: ComposerAgentControl,
@@ -913,7 +869,7 @@ export function renderComposerAgentControl(
control.harnessCommands.root.hidden = state.agent === "codex";
control.harnessCommands.root.style.display = state.agent === "codex" ? "none" : "inline-flex";
renderRendererCreditsControl(control.credits, accountCredits, locale);
- return !submissionBlocked && (state.agent === "codex" || adapterState === "ready");
+ return state.agent !== "codex" && adapterState === "ready" && !submissionBlocked;
}
export function disposeComposerAgentControl(control: ComposerAgentControl): void {
@@ -925,7 +881,6 @@ export function disposeComposerAgentControl(control: ComposerAgentControl): void
}
restoreNativeControl(control.nativeModelControl);
restoreNativeControl(control.nativeContextUsageControl);
- restoreNativeControl(control.nativeUsageBanner);
restoreNativeControl(control.nativePermissionModeControl);
control.credits.dispose();
control.usage?.dispose();
diff --git a/packages/renderer-extension/src/settings/appearance-page.ts b/packages/renderer-extension/src/settings/appearance-page.ts
index 424eeee0b..51607c188 100644
--- a/packages/renderer-extension/src/settings/appearance-page.ts
+++ b/packages/renderer-extension/src/settings/appearance-page.ts
@@ -1,9 +1,4 @@
import { readCpaLiveVoiceHookConfig } from "../renderer-cpa-live-voice-hook.js";
-import {
- CODEX_USAGE_BANNER_CHANGE_EVENT,
- readCodexUsageBannerPreference,
- writeCodexUsageBannerPreference,
-} from "../renderer-codex-usage-banner-preference.js";
import {
CPA_LIVE_VOICE_CHANGE_EVENT,
readCpaLiveVoicePreference,
@@ -46,32 +41,6 @@ export function createAppearanceSettingsPage(
description.textContent = messages.appearanceDescription;
const { group, card } = createPreferenceGroup(document, messages.appearanceGroup);
- const usageBannerId = preferenceId("hide-codex-usage-banner");
- const usageBannerRow = createPreferenceItem(document, {
- title: messages.hideCodexUsageBannerTitle,
- description: messages.hideCodexUsageBannerDescription,
- controlId: usageBannerId,
- help: {
- label: messages.hideCodexUsageBannerHelpLabel,
- lines: messages.hideCodexUsageBannerHelp,
- },
- });
- const usageBanner = createPreferenceSwitch(
- document,
- usageBannerId,
- usageBannerRow.description.id,
- );
- usageBanner.checked = readCodexUsageBannerPreference(ownerWindow);
- usageBanner.addEventListener("change", () => {
- writeCodexUsageBannerPreference(ownerWindow, usageBanner.checked);
- });
- const syncUsageBanner = (): void => {
- usageBanner.checked = readCodexUsageBannerPreference(ownerWindow);
- };
- ownerWindow.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, syncUsageBanner);
- usageBannerRow.item.append(usageBanner);
- card.append(usageBannerRow.item);
-
const liveVoiceId = preferenceId("cpa-live-voice");
const liveVoiceRow = createPreferenceItem(document, {
title: messages.cpaLiveVoiceTitle,
@@ -123,7 +92,6 @@ export function createAppearanceSettingsPage(
getLoadedSessionsClient,
);
return () => {
- ownerWindow.removeEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, syncUsageBanner);
ownerWindow.removeEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, syncLiveVoice);
ownerWindow.removeEventListener(REASONING_SOFT_WRAP_CHANGE_EVENT, sync);
disposeIdleRelease();
diff --git a/packages/renderer-extension/src/settings/localization.ts b/packages/renderer-extension/src/settings/localization.ts
index bdaea37f3..88a89709c 100644
--- a/packages/renderer-extension/src/settings/localization.ts
+++ b/packages/renderer-extension/src/settings/localization.ts
@@ -38,10 +38,6 @@ export interface RendererSettingsMessages {
readonly otherSection: string;
readonly appearanceDescription: string;
readonly appearanceGroup: string;
- readonly hideCodexUsageBannerTitle: string;
- readonly hideCodexUsageBannerDescription: string;
- readonly hideCodexUsageBannerHelpLabel: string;
- readonly hideCodexUsageBannerHelp: readonly string[];
readonly cpaLiveVoiceTitle: string;
readonly cpaLiveVoiceDescription: string;
readonly cpaLiveVoiceHelpLabel: string;
@@ -299,14 +295,6 @@ const ENGLISH_MESSAGES: RendererSettingsMessages = Object.freeze({
otherSection: "Other",
appearanceDescription: "Conversation display and local resource management.",
appearanceGroup: "Appearance",
- hideCodexUsageBannerTitle: "Hide Codex usage warning",
- hideCodexUsageBannerDescription: "On by default. Hide the out-of-usage card on every Composer.",
- hideCodexUsageBannerHelpLabel: "About hiding the Codex usage warning",
- hideCodexUsageBannerHelp: Object.freeze([
- "The native card appears when the signed-in ChatGPT Codex quota is exhausted.",
- "When this is on, that card is hidden on every Composer, including Codex threads. Reset usage and Upgrade stay available in Settings.",
- "Turning this off only shows the native card again. Send on this Composer, including Codex threads, is already separated from that quota card.",
- ]),
cpaLiveVoiceTitle: "Override live voice",
cpaLiveVoiceDescription: "On by default. Host creates the realtime call instead of ChatGPT.",
cpaLiveVoiceHelpLabel: "About overriding live voice",
@@ -625,14 +613,6 @@ const CHINESE_MESSAGES: RendererSettingsMessages = Object.freeze({
otherSection: "其他",
appearanceDescription: "会话显示与本地资源管理。",
appearanceGroup: "外观",
- hideCodexUsageBannerTitle: "隐藏 Codex 额度警告",
- hideCodexUsageBannerDescription: "默认开启。在所有 Composer 上隐藏额度耗尽卡片。",
- hideCodexUsageBannerHelpLabel: "隐藏 Codex 额度警告说明",
- hideCodexUsageBannerHelp: Object.freeze([
- "已登录的 ChatGPT Codex 额度耗尽时,Desktop 会显示原生警告卡片。",
- "开启后,所有 Composer 都隐藏该卡片,包括 Codex 会话。「重置用量」和「升级」仍可在设置中使用。",
- "关闭此开关只会重新显示原生卡片。当前 Composer 的发送(包括 Codex 会话)已与该额度卡片分离。",
- ]),
cpaLiveVoiceTitle: "改写实时语音",
cpaLiveVoiceDescription: "默认开启。由 Host 创建 realtime 通话,而不是 ChatGPT。",
cpaLiveVoiceHelpLabel: "改写实时语音说明",
diff --git a/packages/renderer-extension/test/renderer-binding-probe.test.ts b/packages/renderer-extension/test/renderer-binding-probe.test.ts
index a34752a33..b636b8519 100644
--- a/packages/renderer-extension/test/renderer-binding-probe.test.ts
+++ b/packages/renderer-extension/test/renderer-binding-probe.test.ts
@@ -42,9 +42,7 @@ import {
isComposerVoiceButton,
creditsPlacementAnchor,
isNativeContextUsageControlCandidate,
- isNativeUsageBannerCandidate,
nativeContextUsageControlForComposer,
- nativeUsageBannerForComposer,
reconcileComposerNativeControls,
refreshSendButton,
trailingActionAnchor,
@@ -842,152 +840,6 @@ describe("Renderer Composer DOM behavior", () => {
expect(isNativeContextUsageControlCandidate(credits)).toBe(false);
});
- it("recognizes only a unique Codex usage recovery aside", () => {
- const banner = {
- localName: "aside",
- getAttribute: (name: string) =>
- name === "role" ? "status" : name === "aria-live" ? "polite" : null,
- querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
- } as unknown as HTMLElement;
- const unmarked = {
- localName: "aside",
- getAttribute: () => null,
- querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
- } as unknown as HTMLElement;
- const notice = {
- localName: "aside",
- getAttribute: (name: string) =>
- name === "role" ? "status" : name === "aria-live" ? "polite" : null,
- querySelector: () => null,
- } as unknown as HTMLElement;
- expect(isNativeUsageBannerCandidate(banner)).toBe(true);
- expect(isNativeUsageBannerCandidate(unmarked)).toBe(true);
- expect(isNativeUsageBannerCandidate(notice)).toBe(false);
- const composer = {
- querySelectorAll: (selector: string) => (selector === "aside" ? [unmarked] : []),
- } as unknown as Element;
- expect(nativeUsageBannerForComposer(composer)).toBe(unmarked);
- const ambiguous = {
- querySelectorAll: () => [banner, unmarked],
- } as unknown as Element;
- expect(nativeUsageBannerForComposer(ambiguous)).toBeNull();
- });
-
- it("hides the Codex usage banner on any Composer by default, including Codex threads", () => {
- vi.stubGlobal("document", { activeElement: null });
- const banner = {
- localName: "aside",
- hidden: false,
- getAttribute: (name: string) => {
- if (name === "role") return "status";
- if (name === "aria-live") return "polite";
- if (name === "aria-hidden") return banner.ariaHidden;
- if (name === "aria-expanded") return null;
- return null;
- },
- setAttribute: (name: string, value: string) => {
- if (name === "aria-hidden") banner.ariaHidden = value;
- },
- removeAttribute: (name: string) => {
- if (name === "aria-hidden") banner.ariaHidden = null;
- },
- querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
- contains: () => false,
- click: vi.fn(),
- ariaHidden: null as string | null,
- };
- const control = {
- composer: {
- querySelectorAll: (selector: string) => (selector === "aside" ? [banner] : []),
- },
- modelPicker: { root: { parentElement: null }, trigger: {} },
- nativeModelControl: null,
- nativePermissionModeControl: null,
- nativeContextUsageControl: null,
- nativeUsageBanner: null,
- credits: {
- anchor: null,
- place: vi.fn(),
- root: { remove: vi.fn() },
- },
- usage: {
- anchor: null,
- place: vi.fn(),
- root: { remove: vi.fn() },
- },
- } as unknown as ComposerAgentControl;
-
- reconcileComposerNativeControls(control, true, true);
- expect(banner.hidden).toBe(true);
- expect(banner.ariaHidden).toBe("true");
- expect(control.nativeUsageBanner?.element).toBe(banner);
-
- // The aside reports the account-wide ChatGPT rate limit and upsell, so a
- // Codex Thread Composer hides it under the same preference.
- reconcileComposerNativeControls(control, false, false);
- expect(banner.hidden).toBe(true);
- expect(banner.ariaHidden).toBe("true");
- vi.unstubAllGlobals();
- });
-
- it("keeps the Codex usage banner when the Settings preference is off", () => {
- vi.stubGlobal("document", { activeElement: null, defaultView: null });
- const owner = Object.assign(new EventTarget(), {
- localStorage: { getItem: () => "0", setItem: vi.fn() },
- });
- const banner = {
- localName: "aside",
- hidden: false,
- getAttribute: (name: string) => {
- if (name === "role") return "status";
- if (name === "aria-live") return "polite";
- if (name === "aria-hidden") return banner.ariaHidden;
- if (name === "aria-expanded") return null;
- return null;
- },
- setAttribute: (name: string, value: string) => {
- if (name === "aria-hidden") banner.ariaHidden = value;
- },
- removeAttribute: (name: string) => {
- if (name === "aria-hidden") banner.ariaHidden = null;
- },
- querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
- contains: () => false,
- click: vi.fn(),
- ariaHidden: null as string | null,
- };
- const control = {
- composer: {
- ownerDocument: { defaultView: owner },
- querySelectorAll: (selector: string) => (selector === "aside" ? [banner] : []),
- },
- modelPicker: { root: { parentElement: null }, trigger: {} },
- nativeModelControl: null,
- nativePermissionModeControl: null,
- nativeContextUsageControl: null,
- nativeUsageBanner: null,
- credits: {
- anchor: null,
- place: vi.fn(),
- root: { remove: vi.fn() },
- },
- usage: {
- anchor: null,
- place: vi.fn(),
- root: { remove: vi.fn() },
- },
- } as unknown as ComposerAgentControl;
-
- reconcileComposerNativeControls(control, true, true);
- expect(banner.hidden).toBe(false);
- expect(banner.ariaHidden).toBeNull();
-
- reconcileComposerNativeControls(control, false, false);
- expect(banner.hidden).toBe(false);
- expect(banner.ariaHidden).toBeNull();
- vi.unstubAllGlobals();
- });
-
it("does not treat attachment controls as submission", () => {
const button = (label: string, type = "button") =>
({
diff --git a/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts b/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
deleted file mode 100644
index 6050d7c29..000000000
--- a/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-import {
- CODEX_USAGE_BANNER_CHANGE_EVENT,
- CODEX_USAGE_BANNER_STORAGE_KEY,
- readCodexUsageBannerPreference,
- writeCodexUsageBannerPreference,
-} from "../src/renderer-codex-usage-banner-preference.js";
-
-function fixture(initial?: string) {
- const values = new Map();
- if (initial !== undefined) values.set(CODEX_USAGE_BANNER_STORAGE_KEY, initial);
- const storage = {
- getItem: vi.fn((key: string) => values.get(key) ?? null),
- setItem: vi.fn((key: string, value: string) => {
- values.set(key, value);
- }),
- };
- const owner = Object.assign(new EventTarget(), { localStorage: storage }) as unknown as Window;
- return { owner, values };
-}
-
-describe("Codex usage banner Settings preference", () => {
- it("defaults on, persists off as 0, and stays on without a Window", () => {
- const unset = fixture();
- expect(readCodexUsageBannerPreference(unset.owner)).toBe(true);
- expect(readCodexUsageBannerPreference(null)).toBe(true);
- expect(readCodexUsageBannerPreference(undefined)).toBe(true);
-
- const events: string[] = [];
- unset.owner.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, () => events.push("changed"));
- expect(writeCodexUsageBannerPreference(unset.owner, false)).toBe(true);
- expect(unset.values.get(CODEX_USAGE_BANNER_STORAGE_KEY)).toBe("0");
- expect(readCodexUsageBannerPreference(unset.owner)).toBe(false);
- expect(events).toEqual(["changed"]);
-
- expect(writeCodexUsageBannerPreference(unset.owner, true)).toBe(true);
- expect(unset.values.get(CODEX_USAGE_BANNER_STORAGE_KEY)).toBe("1");
- expect(readCodexUsageBannerPreference(unset.owner)).toBe(true);
- });
-
- it("stays on when localStorage throws", () => {
- const owner = Object.assign(new EventTarget(), {
- localStorage: {
- getItem: () => {
- throw new Error("blocked");
- },
- setItem: () => {
- throw new Error("blocked");
- },
- },
- }) as unknown as Window;
- expect(readCodexUsageBannerPreference(owner)).toBe(true);
- expect(writeCodexUsageBannerPreference(owner, false)).toBe(false);
- });
-});
diff --git a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts
index 1b039647c..aa6c371fe 100644
--- a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts
+++ b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts
@@ -12,21 +12,18 @@ function required(value: T | undefined): T {
}
/** Desktop-shaped selectors: an Account rate-limit gate and a reserve gate. */
-function desktopStore(options: { accountReadsActive?: boolean } = {}) {
+function desktopStore() {
let authMethod = "chatgpt";
let allowed = false;
let hardBlocked = false;
- let active = false;
const auth: Atom = { read: () => ({ authMethod, authenticatedAccountId: "account" }) };
const usage: Atom = { read: () => ({ data: { rate_limit: { allowed } } }) };
- const reserve: Atom = { read: () => ({ active, eligible: true, hardBlocked }) };
+ const reserve: Atom = { read: () => ({ active: false, eligible: true, hardBlocked }) };
const accountGate: Atom = {
read: (get) => {
const a = get(auth) as { authMethod: string };
// Like Desktop: API-key sign-in returns before reading ChatGPT usage.
if (a.authMethod !== "chatgpt") return false;
- // 26.1007: local reserve-active is an exemption inside the account selector.
- if (options.accountReadsActive && (get(reserve) as { active: boolean }).active) return false;
return (
(get(usage) as { data: { rate_limit: { allowed: boolean } } }).data.rate_limit.allowed ===
false
@@ -74,10 +71,6 @@ function desktopStore(options: { accountReadsActive?: boolean } = {}) {
hardBlocked = value;
emit();
},
- setActive(value: boolean) {
- active = value;
- emit();
- },
listenerCount: () => [...listeners.values()].reduce((n, set) => n + set.size, 0),
};
}
@@ -88,23 +81,19 @@ function composerFixture(
options: {
omitReserve?: boolean;
duplicateAccount?: boolean;
- duplicateAccountAtom?: boolean;
snapshotWrapper?: boolean;
signalReserve?: boolean;
- descendantGates?: boolean;
} = {},
) {
const subscribers: Array<{ getSnapshot(): unknown }> = [];
const instances: Array<{ value: unknown; getSnapshot(): unknown }> = [];
const hooks: Array> = [];
const rerender = vi.fn();
- const extraAccount: Atom = { read: (get) => source.accountGate.read(get) };
for (const selector of [
source.reserveActive,
source.accountGate,
...(options.omitReserve ? [] : [source.reserveGate]),
...(options.duplicateAccount ? [source.accountGate] : []),
- ...(options.duplicateAccountAtom ? [extraAccount] : []),
]) {
// Desktop 26.928 wraps parameterized selectors in a readonly signal atom.
const signal = options.signalReserve && selector !== source.accountGate;
@@ -149,23 +138,10 @@ function composerFixture(
effect.create();
}
// Desktop owners have more than a thousand hooks; do not rely on positions.
- const fillers: Array> = Array.from({ length: 230 }, () => ({
- memoizedState: null,
- }));
- const gateHooks = [...hooks];
- if (options.descendantGates) {
- gateHooks.forEach((hook, i) => {
- hook.next = gateHooks[i + 1] ?? null;
- });
- fillers.forEach((hook, i) => {
- hook.next = fillers[i + 1] ?? null;
- });
- } else {
- hooks.unshift(...fillers);
- hooks.forEach((hook, i) => {
- hook.next = hooks[i + 1] ?? null;
- });
- }
+ hooks.unshift(...Array.from({ length: 230 }, () => ({ memoizedState: null })));
+ hooks.forEach((hook, i) => {
+ hook.next = hooks[i + 1] ?? null;
+ });
const props = { onLocalSubmitStart() {}, submitDisabled: false };
// Desktop wraps the owner in a pass-through component with the same props.
const wrapper = {
@@ -173,22 +149,11 @@ function composerFixture(
memoizedState: { memoizedState: null, next: null },
return: null,
};
- const owner: Record = {
+ const owner = {
memoizedProps: props,
- memoizedState: (options.descendantGates ? fillers[0] : hooks[0]) as unknown,
+ memoizedState: hooks[0] as unknown,
return: wrapper,
};
- if (options.descendantGates) {
- // Newer Desktop versions subscribe the gates in the component that
- // receives `submitDisabled` below the owner, not on the owner itself.
- owner.child = {
- memoizedProps: { submitDisabled: false },
- memoizedState: gateHooks[0] as unknown,
- child: null,
- sibling: null,
- return: owner,
- };
- }
const editor = { parentElement: null, __reactFiber$test: owner };
const composer = {
isConnected: true,
@@ -232,25 +197,6 @@ describe("Codex usage gate for external Harness Composers", () => {
expect(f.blocked()).toBe(true);
});
- it("binds gate subscriptions a descendant holds below the submit owner", () => {
- const f = composerFixture(undefined, { descendantGates: true });
- expect(inspectComposerCodexUsageGate(f.composer)).toEqual({
- ownerCount: 1,
- reserveGateCount: 1,
- accountGateCount: 1,
- });
- expect(f.blocked()).toBe(true);
-
- expect(f.gate.update(true)).toBe("bypassed");
- expect(f.blocked()).toBe(false);
- expect(f.rerender).toHaveBeenCalled();
- expect(f.gate.refresh()).toBe("bypassed");
- expect(f.source.store.set).not.toHaveBeenCalled();
-
- expect(f.gate.update(false)).toBe("native");
- expect(f.blocked()).toBe(true);
- });
-
it("binds 26.928 signal selectors and lazy snapshot wrappers", () => {
const source = desktopStore();
source.setHardBlocked(true);
@@ -447,11 +393,9 @@ describe("Codex usage gate for external Harness Composers", () => {
it("keeps native restrictions when the contract is missing, ambiguous or read-only", () => {
for (const options of [
{ omitReserve: true },
- { duplicateAccountAtom: true },
+ { duplicateAccount: true },
{ omitReserve: true, snapshotWrapper: true, signalReserve: true },
- { duplicateAccountAtom: true, snapshotWrapper: true, signalReserve: true },
- { omitReserve: true, descendantGates: true },
- { duplicateAccountAtom: true, descendantGates: true },
+ { duplicateAccount: true, snapshotWrapper: true, signalReserve: true },
]) {
const f = composerFixture(undefined, options);
expect(f.gate.update(true)).toBe("unsupported");
@@ -463,46 +407,6 @@ describe("Codex usage gate for external Harness Composers", () => {
expect(f.blocked()).toBe(true);
});
- it("accepts an account gate with a local reserve-active exemption", () => {
- const source = desktopStore({ accountReadsActive: true });
- const f = composerFixture(source);
- expect(inspectComposerCodexUsageGate(f.composer)).toEqual({
- ownerCount: 1,
- reserveGateCount: 1,
- accountGateCount: 1,
- });
- expect(f.blocked()).toBe(true);
- expect(f.gate.update(true)).toBe("bypassed");
- expect(f.blocked()).toBe(false);
- expect(required(f.instances[0]).getSnapshot()).toBe(false);
- f.gate.update(false);
- expect(f.blocked()).toBe(true);
- source.setActive(true);
- expect(required(f.instances[1]).getSnapshot()).toBe(false);
- });
-
- it("projects same-atom account subscriptions on the owner and a descendant", () => {
- const f = composerFixture(undefined, { duplicateAccount: true, descendantGates: true });
- expect(inspectComposerCodexUsageGate(f.composer).accountGateCount).toBe(2);
- expect(f.blocked()).toBe(true);
- expect(f.gate.update(true)).toBe("bypassed");
- expect(f.blocked()).toBe(false);
- expect(f.gate.refresh()).toBe("bypassed");
- f.gate.dispose();
- expect(f.blocked()).toBe(true);
- });
-
- it("rejects a duplicate account wrapper and restores every projected member", () => {
- const f = composerFixture(undefined, { duplicateAccount: true, snapshotWrapper: true });
- Object.assign(required(f.subscribers[3]), {
- createRender: () => ({ getSnapshot: () => true }),
- });
- const original = required(f.instances[1]).getSnapshot;
- expect(f.gate.update(true)).toBe("unsupported");
- expect(required(f.instances[1]).getSnapshot).toBe(original);
- expect(f.blocked()).toBe(true);
- });
-
it("counts owner and gates for the contract audit without lifting them", () => {
const f = composerFixture();
const sub = f.source.store.sub;
diff --git a/packages/renderer-extension/test/settings/localization.test.ts b/packages/renderer-extension/test/settings/localization.test.ts
index 006ccc5b6..3ad04d3b3 100644
--- a/packages/renderer-extension/test/settings/localization.test.ts
+++ b/packages/renderer-extension/test/settings/localization.test.ts
@@ -33,10 +33,6 @@ describe("Renderer settings localization", () => {
expect(english.title).toBe("Settings");
expect(chinese.title).toBe("设置");
- expect(english.hideCodexUsageBannerTitle).toBe("Hide Codex usage warning");
- expect(chinese.hideCodexUsageBannerTitle).toBe("隐藏 Codex 额度警告");
- expect(english.hideCodexUsageBannerDescription).toContain("On by default");
- expect(chinese.hideCodexUsageBannerDescription).toContain("默认开启");
expect(english.cpaLiveVoiceTitle).toBe("Override live voice");
expect(chinese.cpaLiveVoiceTitle).toBe("改写实时语音");
expect(english.cpaLiveVoiceTitle.toLowerCase()).not.toContain("cpa");
diff --git a/tools/codex-desktop-contract-audit/README.md b/tools/codex-desktop-contract-audit/README.md
index ed91c2ef6..368060c07 100644
--- a/tools/codex-desktop-contract-audit/README.md
+++ b/tools/codex-desktop-contract-audit/README.md
@@ -38,11 +38,11 @@ The `codex-usage-gate` surface covers `renderer-codex-usage-gate.ts`, which lets
- `ownerCount` — Composer owners exposing `onLocalSubmitStart` and boolean `submitDisabled` that hold the reserve gate (pass-through wrappers with the same props are excluded);
- `reserveGateCount` — boolean subscriptions whose selector reads reserve `hardBlocked`;
-- `accountGateCount` — boolean subscriptions whose selector reads `authMethod` and `rate_limit.allowed`. One logical gate may appear as several same-atom subscriptions.
+- `accountGateCount` — boolean subscriptions whose selector reads `authMethod` and `rate_limit.allowed`.
Verdicts:
-- owner count other than one per Composer, or fewer reserve gates than Composers, is `confirmed-impact`;
+- an owner or reserve count other than one per Composer, or more than one Account gate, is `confirmed-impact`;
- no Account gate is `unverified` (`codex-usage-account-gate-dormant`): the selector returns early for API-key sign-in or while Account data loads, so inspect again signed in with ChatGPT;
- no Composer with an editor is `unverified`.
diff --git a/tools/codex-desktop-contract-audit/report.mjs b/tools/codex-desktop-contract-audit/report.mjs
index f7d7ec034..10666df63 100644
--- a/tools/codex-desktop-contract-audit/report.mjs
+++ b/tools/codex-desktop-contract-audit/report.mjs
@@ -404,15 +404,16 @@ export function buildSurfaceResults(contracts, baseline = null, controlled = nul
baseline,
});
// External Harness submission while ChatGPT Codex usage is exhausted depends
- // on one Composer owner exposing the reserve gate and, when it reads usage,
- // the Account gate (renderer-codex-usage-gate.ts). One logical gate may have
- // several same-atom subscriptions. The Account gate returns early for API-key
- // sign-in or loading, so its absence is unverified.
+ // on one Composer owner exposing one reserve gate and, when it reads usage,
+ // one Account gate (renderer-codex-usage-gate.ts). The Account gate returns
+ // early for API-key sign-in or loading, so its absence is unverified.
const gate = contracts.codexUsageGate;
const gateActive = gate.composerCount > 0;
const gateStructure =
- gate.ownerCount === gate.composerCount && gate.reserveGateCount >= gate.composerCount;
- const accountGateObserved = gate.accountGateCount >= gate.composerCount;
+ gate.ownerCount === gate.composerCount &&
+ gate.reserveGateCount === gate.composerCount &&
+ gate.accountGateCount <= gate.composerCount;
+ const accountGateObserved = gate.accountGateCount === gate.composerCount;
const codexUsageGate = classifySurface({
id: "codex-usage-gate",
observed: gate,
diff --git a/tools/codex-desktop-contract-audit/report.test.mjs b/tools/codex-desktop-contract-audit/report.test.mjs
index 355761b78..a9168db15 100644
--- a/tools/codex-desktop-contract-audit/report.test.mjs
+++ b/tools/codex-desktop-contract-audit/report.test.mjs
@@ -76,7 +76,6 @@ describe("Codex Desktop contract audit report", () => {
verdict: "unverified",
reason: "codex-usage-account-gate-dormant",
});
- expect(verdictFor({ accountGateCount: 2 })?.verdict).toBe("no-impact");
expect(verdictFor({ composerCount: 0, ownerCount: 0, reserveGateCount: 0 })?.verdict).toBe(
"unverified",
);
From b9c4c9eef88e4e8f4f973089650a2b35068ec2e0 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:49:27 +0900
Subject: [PATCH 09/98] docs: retarget fork versioning to upstream 0.13.3
Record shipped v0.13.2-3 and the next unused counter v0.13.3-0 after
syncing BytePioneer 0.13.3.
---
.agents/skills/codexhost-release/SKILL.md | 4 ++--
docs/index.md | 2 +-
docs/operations/codexhost-console.md | 2 +-
docs/operations/fork-versioning.md | 22 +++++++++++-----------
4 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/.agents/skills/codexhost-release/SKILL.md b/.agents/skills/codexhost-release/SKILL.md
index 33a845ed6..315327dc0 100644
--- a/.agents/skills/codexhost-release/SKILL.md
+++ b/.agents/skills/codexhost-release/SKILL.md
@@ -17,7 +17,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
- `.github/workflows/release-packages.yml` 与 `.github/workflows/ci.yml`
- `packages/repository-automation/src/release.mjs`
- `docs/operations/repository-maintenance.md`
-- 本仓库若是 fork `karlorz/codex-host`:先读 [fork versioning](../../../docs/operations/fork-versioning.md)(`0.13.2-0` / `0.13.2-1`…,控制台版本同源),再读本 Skill 的上游渠道表
+- 本仓库若是 fork `karlorz/codex-host`:先读 [fork versioning](../../../docs/operations/fork-versioning.md)(`0.13.3-0` / `0.13.3-1`…,控制台版本同源),再读本 Skill 的上游渠道表
- 涉及更新提示时:`packages/update-manager/src/github-release.ts`
源码和工作流是执行行为的依据。发现指南与当前实现不一致时,说明差异;不要假装已有不支持的发布能力。
@@ -39,7 +39,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
用户意图不清楚时,先问清两个渠道的目标。最终执行前明确报告版本、npm dist-tag、GitHub 状态与是否提示更新。
-在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 仍为 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`、`v0.13.2-2`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 已启用,Trusted Publisher 指向 `karlorz/codex-host`;数字计数(如 `0.13.2-2`)由工作流发成 npm `latest` + GitHub latest。控制台「codexhost 版本」必须与该准备版本一致。`gh pr create` / `gh release` 必须带 `-R karlorz/codex-host`。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
+在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 为 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-1`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 已启用,Trusted Publisher 指向 `karlorz/codex-host`;数字计数(如 `0.13.3-0`)由工作流发成 npm `latest` + GitHub latest。控制台「codexhost 版本」必须与该准备版本一致。`gh pr create` / `gh release` 必须带 `-R karlorz/codex-host`。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
## 2. 检查现场与验证
diff --git a/docs/index.md b/docs/index.md
index 4dedbcce9..ba9291019 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -131,7 +131,7 @@
| [`operations/remote-thread-read.md`](operations/remote-thread-read.md) | 通过现有 Desktop 远程连接跨 Harness 读取会话;包含部署要求、分页、响应大小与超时限制。 |
| [`operations/host-runtime-log.md`](operations/host-runtime-log.md) | Host Runtime 日志文件的位置、内容与边界;排查 Runtime 崩溃或异常退出时阅读。 |
| [`operations/codexhost-console.md`](operations/codexhost-console.md) | 本地控制台的打开方式、页面、项目公告、与运行中 Host 的连接、端口、诊断文件、安全与更新边界;排查启动失败或维护 Web 公告时阅读。 |
-| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.2` 时用 `v0.13.2-0`、`v0.13.2-1`…;已发 `v0.13.2-2` 为 GitHub latest 与 npm `@karlorz/codexhost-cli@latest`;数字计数走 `Release packages` latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
+| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-1`…;已发 `v0.13.2-3` 为 GitHub latest 与 npm `@karlorz/codexhost-cli@latest`;数字计数走 `Release packages` latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
| [`NOTICE.md`](NOTICE.md) | Web 控制台打开或刷新时读取的项目公告,含显示开关、标题、类型与正文;默认关闭,发布规则见控制台文档。 |
| [`operations/repository-maintenance.md`](operations/repository-maintenance.md) | PR 标签、CI 评论和发布前检查自动化;修改仓库自动化时阅读。 |
diff --git a/docs/operations/codexhost-console.md b/docs/operations/codexhost-console.md
index 0983044ce..50b1df2ff 100644
--- a/docs/operations/codexhost-console.md
+++ b/docs/operations/codexhost-console.md
@@ -2,7 +2,7 @@
控制台是一个本地网页,不依赖 Codex Desktop 启动成功。左侧导航分为:
-- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。安装包上的 **codexhost 版本** 来自 `app/codexhost-distribution.json`。`release:prepare` 只同步 `package.json` / lock / Cargo;distribution 文件由随后的 `release:package` 或 `release:npm` 写出。本 fork 上该数字必须等于当前 fork 标签,例如已发 `v0.13.2-2` 时显示 `0.13.2-2`,见 [fork-versioning.md](fork-versioning.md)。热装现有 app 时需重打包或直接改写已安装的 `codexhost-distribution.json`。
+- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。安装包上的 **codexhost 版本** 来自 `app/codexhost-distribution.json`。`release:prepare` 只同步 `package.json` / lock / Cargo;distribution 文件由随后的 `release:package` 或 `release:npm` 写出。本 fork 上该数字必须等于当前 fork 标签,例如已发 `v0.13.2-3` 时显示 `0.13.2-3`,见 [fork-versioning.md](fork-versioning.md)。热装现有 app 时需重打包或直接改写已安装的 `codexhost-distribution.json`。
- **设置**:连接、远程连接、账号、会话导入、更新,与 Codex 设置页使用同一套页面和 Host 接口。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。
- **其他**:关于。
diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md
index a5218ed2f..2a6e9a010 100644
--- a/docs/operations/fork-versioning.md
+++ b/docs/operations/fork-versioning.md
@@ -10,14 +10,14 @@ Fork versions stay on the **upstream version that is currently held**, plus a nu
| Upstream holds | First fork tag | Next fork tags |
| --- | --- | --- |
-| `0.13.2` | `v0.13.2-0` | `v0.13.2-1`, `v0.13.2-2`, … |
-| later `0.13.3` | `v0.13.3-0` | `v0.13.3-1`, … |
+| `0.13.2` | `v0.13.2-0` | `v0.13.2-1`, `v0.13.2-2`, `v0.13.2-3` |
+| `0.13.3` | `v0.13.3-0` | `v0.13.3-1`, … |
- Read upstream from `upstream/main` `package.json` and the upstream GitHub latest release. If they disagree, stop and report; do not guess.
- `n` is the next unused non-negative integer for that upstream version on the fork (`0` if none exist).
-- Do **not** invent a newer minor or patch than upstream (for example `0.13.3-cpa.1` while upstream still holds `0.13.2`).
-- `v0.13.2-0` is valid SemVer (prerelease `0` of `0.13.2`). Prepare it with `npm run release:prepare -- 0.13.2-0`.
-- Numeric prerelease `0.13.2-0` compares **below** upstream `0.13.2`. The in-app updater and Settings command must therefore **not** read BytePioneer `releases/latest` or install `@codexhost/cli`. They use `karlorz/codex-host` and `@karlorz/codexhost-cli` instead.
+- Do **not** invent a newer minor or patch than upstream, and do **not** add a named suffix such as `0.13.3-cpa.1`. While upstream holds `0.13.3`, the first fork tag is `v0.13.3-0`.
+- `v0.13.3-0` is valid SemVer (prerelease `0` of `0.13.3`). Prepare it with `npm run release:prepare -- 0.13.3-0`.
+- Numeric prerelease `0.13.3-0` compares **below** upstream `0.13.3`. The in-app updater and Settings command must therefore **not** read BytePioneer `releases/latest` or install `@codexhost/cli`. They use `karlorz/codex-host` and `@karlorz/codexhost-cli` instead.
The four prepared files (`package.json`, `package-lock.json`, `Cargo.toml`, `Cargo.lock`) must equal that version. Packaged `app/codexhost-distribution.json` is written later by `release:package` / `release:npm`, not by `release:prepare`. The Web console overview **codexhost 版本** reads that distribution file (installer/npm) or workspace `package.json` (source). After prepare, rebuild or recopy the installed `codexhost-distribution.json` so the live console matches the tag.
@@ -42,9 +42,9 @@ A fork tag is not shipped until both of these exist for that version:
1. **npm:** `Release packages` publishes the packed meta package and matching platform tarballs under `@karlorz` with dist-tag `latest` (or `next` / `test` only for named SemVer prereleases). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`. After CI logs `+ @karlorz/…@`, npm may still 404 for a few minutes while provenance is processed; recurl the version URL before republishing.
2. **GitHub:** a **latest** (non-draft, non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build.
-Numeric fork counters such as `0.13.2-2` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`) stay on `next` / `test` and GitHub prerelease. `packages/repository-automation/src/release.mjs` treats a hyphen plus digits-only (`0.13.2-2`) as latest, not as `next`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
+Numeric fork counters such as `0.13.3-0` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`) stay on `next` / `test` and GitHub prerelease. `packages/repository-automation/src/release.mjs` treats a hyphen plus digits-only (`0.13.3-0`) as latest, not as `next`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
-Shipped latest on this fork is `v0.13.2-2`: GitHub latest plus `@karlorz/codexhost-cli@0.13.2-2` and all six platform packages on npm `latest`.
+Shipped latest on this fork is `v0.13.2-3`: GitHub latest plus `@karlorz/codexhost-cli@0.13.2-3` and all six platform packages on npm `latest`. The next unused counter after syncing upstream `0.13.3` is `0.13.3-0`.
## GitHub Actions secrets
@@ -78,15 +78,15 @@ gh secret set FORK_SYNC_TOKEN -R karlorz/codex-host
- Never publish `@codexhost/*` from this checkout.
- Do not move, delete, or re-push an existing tag unless the operator explicitly requested that rewrite.
- Create GitHub Releases and pull requests with `-R karlorz/codex-host`. Default `gh` in this checkout resolves to BytePioneer-AI/codex-host. `gh pr create` without `-R` opened the wrong-repo PR #547 and must abort. `gh release create --notes-from-tag` does not work with `--repo`; write the tag annotation to a notes file instead.
-- After the lock is on `origin/main`, the next command is `npm run release:prepare -- 0.13.2-` on `main`. Merge of an identity-lock PR is not a release.
+- After the lock is on `origin/main`, the next command is `npm run release:prepare -- 0.13.3-0` on `main`. Merge of an identity-lock PR is not a release.
## Next unused counter
```bash
git fetch upstream --tags --prune
git fetch origin --tags --prune
-# example when upstream holds 0.13.2:
-git ls-remote --tags origin 'refs/tags/v0.13.2-*'
+# example when upstream holds 0.13.3:
+git ls-remote --tags origin 'refs/tags/v0.13.3-*'
```
-Shipped tags include `v0.13.2-0`, `v0.13.2-1`, and `v0.13.2-2`. Pick the smallest `n` that is not already a fork tag (next is `0.13.2-3`). Then `npm run release:prepare -- 0.13.2-` on `main`, push the four version files, wait for that SHA's `main` push CI, then push the annotated tag.
+Shipped tags include `v0.13.2-0`, `v0.13.2-1`, `v0.13.2-2`, and `v0.13.2-3`. After upstream `0.13.3`, pick the smallest unused `n` for that version (next is `0.13.3-0`). Then `npm run release:prepare -- 0.13.3-0` on `main`, push the four version files, wait for that SHA's `main` push CI, then push the annotated tag.
From b7a8ca1ab2902366306ee28d5178ff390447052a Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 02:14:59 +0900
Subject: [PATCH 10/98] fix: treat hyphenated fork counters such as 0.13.3-0-1
as latest
Keep 0.13.3-cpa.1 on the named-prerelease path. First fork of upstream
0.13.3 stays 0.13.3-0; later counters on that version are 0.13.3-0-1.
---
.agents/skills/codexhost-release/SKILL.md | 4 ++--
docs/index.md | 2 +-
docs/operations/fork-versioning.md | 12 ++++++------
packages/repository-automation/src/release.mjs | 2 +-
packages/repository-automation/test/release.test.mjs | 3 +++
5 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/.agents/skills/codexhost-release/SKILL.md b/.agents/skills/codexhost-release/SKILL.md
index 315327dc0..a40744318 100644
--- a/.agents/skills/codexhost-release/SKILL.md
+++ b/.agents/skills/codexhost-release/SKILL.md
@@ -17,7 +17,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
- `.github/workflows/release-packages.yml` 与 `.github/workflows/ci.yml`
- `packages/repository-automation/src/release.mjs`
- `docs/operations/repository-maintenance.md`
-- 本仓库若是 fork `karlorz/codex-host`:先读 [fork versioning](../../../docs/operations/fork-versioning.md)(`0.13.3-0` / `0.13.3-1`…,控制台版本同源),再读本 Skill 的上游渠道表
+- 本仓库若是 fork `karlorz/codex-host`:先读 [fork versioning](../../../docs/operations/fork-versioning.md)(`0.13.3-0` / `0.13.3-0-1`…,控制台版本同源),再读本 Skill 的上游渠道表
- 涉及更新提示时:`packages/update-manager/src/github-release.ts`
源码和工作流是执行行为的依据。发现指南与当前实现不一致时,说明差异;不要假装已有不支持的发布能力。
@@ -39,7 +39,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
用户意图不清楚时,先问清两个渠道的目标。最终执行前明确报告版本、npm dist-tag、GitHub 状态与是否提示更新。
-在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 为 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-1`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 已启用,Trusted Publisher 指向 `karlorz/codex-host`;数字计数(如 `0.13.3-0`)由工作流发成 npm `latest` + GitHub latest。控制台「codexhost 版本」必须与该准备版本一致。`gh pr create` / `gh release` 必须带 `-R karlorz/codex-host`。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
+在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 为 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-0-1`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 已启用,Trusted Publisher 指向 `karlorz/codex-host`;数字计数(如 `0.13.3-0`、`0.13.3-0-1`)由工作流发成 npm `latest` + GitHub latest。控制台「codexhost 版本」必须与该准备版本一致。`gh pr create` / `gh release` 必须带 `-R karlorz/codex-host`。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
## 2. 检查现场与验证
diff --git a/docs/index.md b/docs/index.md
index ba9291019..bee0a635a 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -131,7 +131,7 @@
| [`operations/remote-thread-read.md`](operations/remote-thread-read.md) | 通过现有 Desktop 远程连接跨 Harness 读取会话;包含部署要求、分页、响应大小与超时限制。 |
| [`operations/host-runtime-log.md`](operations/host-runtime-log.md) | Host Runtime 日志文件的位置、内容与边界;排查 Runtime 崩溃或异常退出时阅读。 |
| [`operations/codexhost-console.md`](operations/codexhost-console.md) | 本地控制台的打开方式、页面、项目公告、与运行中 Host 的连接、端口、诊断文件、安全与更新边界;排查启动失败或维护 Web 公告时阅读。 |
-| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-1`…;已发 `v0.13.2-3` 为 GitHub latest 与 npm `@karlorz/codexhost-cli@latest`;数字计数走 `Release packages` latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
+| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-0-1`…;已发 `v0.13.2-3` 为 GitHub latest 与 npm `@karlorz/codexhost-cli@latest`;数字计数走 `Release packages` latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
| [`NOTICE.md`](NOTICE.md) | Web 控制台打开或刷新时读取的项目公告,含显示开关、标题、类型与正文;默认关闭,发布规则见控制台文档。 |
| [`operations/repository-maintenance.md`](operations/repository-maintenance.md) | PR 标签、CI 评论和发布前检查自动化;修改仓库自动化时阅读。 |
diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md
index 2a6e9a010..a2aca2aed 100644
--- a/docs/operations/fork-versioning.md
+++ b/docs/operations/fork-versioning.md
@@ -8,14 +8,14 @@ Package identity follows the same fork-scope rule as other karlorz forks: a perm
Fork versions stay on the **upstream version that is currently held**, plus a numeric fork counter.
-| Upstream holds | First fork tag | Next fork tags |
+| Upstream holds | First fork tag | Later fork tags |
| --- | --- | --- |
| `0.13.2` | `v0.13.2-0` | `v0.13.2-1`, `v0.13.2-2`, `v0.13.2-3` |
-| `0.13.3` | `v0.13.3-0` | `v0.13.3-1`, … |
+| `0.13.3` | `v0.13.3-0` | `v0.13.3-0-1`, `v0.13.3-0-2`, … |
- Read upstream from `upstream/main` `package.json` and the upstream GitHub latest release. If they disagree, stop and report; do not guess.
-- `n` is the next unused non-negative integer for that upstream version on the fork (`0` if none exist).
-- Do **not** invent a newer minor or patch than upstream, and do **not** add a named suffix such as `0.13.3-cpa.1`. While upstream holds `0.13.3`, the first fork tag is `v0.13.3-0`.
+- First fork of an upstream version is `X.Y.Z-0`. Later fork-only releases while that upstream version is still held are `X.Y.Z-0-1`, `X.Y.Z-0-2`, …. The shipped `0.13.2` tags keep their existing single-counter names.
+- Do **not** invent a newer minor or patch than upstream, and do **not** add a named suffix such as `0.13.3-cpa.1`. That tag is legacy and is not a fork release channel.
- `v0.13.3-0` is valid SemVer (prerelease `0` of `0.13.3`). Prepare it with `npm run release:prepare -- 0.13.3-0`.
- Numeric prerelease `0.13.3-0` compares **below** upstream `0.13.3`. The in-app updater and Settings command must therefore **not** read BytePioneer `releases/latest` or install `@codexhost/cli`. They use `karlorz/codex-host` and `@karlorz/codexhost-cli` instead.
@@ -42,7 +42,7 @@ A fork tag is not shipped until both of these exist for that version:
1. **npm:** `Release packages` publishes the packed meta package and matching platform tarballs under `@karlorz` with dist-tag `latest` (or `next` / `test` only for named SemVer prereleases). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`. After CI logs `+ @karlorz/…@`, npm may still 404 for a few minutes while provenance is processed; recurl the version URL before republishing.
2. **GitHub:** a **latest** (non-draft, non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build.
-Numeric fork counters such as `0.13.3-0` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`) stay on `next` / `test` and GitHub prerelease. `packages/repository-automation/src/release.mjs` treats a hyphen plus digits-only (`0.13.3-0`) as latest, not as `next`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
+Numeric fork counters such as `0.13.3-0` and `0.13.3-0-1` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`, `-cpa.1`) stay on `next` / `test` and GitHub prerelease. `packages/repository-automation/src/release.mjs` treats a hyphen plus one or more digit groups (`0.13.3-0`, `0.13.3-0-1`) as latest, not as `next`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
Shipped latest on this fork is `v0.13.2-3`: GitHub latest plus `@karlorz/codexhost-cli@0.13.2-3` and all six platform packages on npm `latest`. The next unused counter after syncing upstream `0.13.3` is `0.13.3-0`.
@@ -89,4 +89,4 @@ git fetch origin --tags --prune
git ls-remote --tags origin 'refs/tags/v0.13.3-*'
```
-Shipped tags include `v0.13.2-0`, `v0.13.2-1`, `v0.13.2-2`, and `v0.13.2-3`. After upstream `0.13.3`, pick the smallest unused `n` for that version (next is `0.13.3-0`). Then `npm run release:prepare -- 0.13.3-0` on `main`, push the four version files, wait for that SHA's `main` push CI, then push the annotated tag.
+Shipped tags include `v0.13.2-0`, `v0.13.2-1`, `v0.13.2-2`, and `v0.13.2-3`. After upstream `0.13.3`, the first unused fork tag is `0.13.3-0`; later ones on the same upstream are `0.13.3-0-1`, `0.13.3-0-2`. Then `npm run release:prepare -- 0.13.3-0` on `main`, push the four version files, wait for that SHA's `main` push CI, then push the annotated tag.
diff --git a/packages/repository-automation/src/release.mjs b/packages/repository-automation/src/release.mjs
index 327208624..4217710a5 100644
--- a/packages/repository-automation/src/release.mjs
+++ b/packages/repository-automation/src/release.mjs
@@ -80,7 +80,7 @@ export async function readReleaseMetadata({ root, tag, expectedRefSha }) {
}
if (!notes.trim()) throw new Error("annotated tag body must contain release notes");
const prereleaseId = version.split("+")[0].split("-").slice(1).join("-");
- const numericForkCounter = /^\d+$/u.test(prereleaseId);
+ const numericForkCounter = /^\d+(?:-\d+)*$/u.test(prereleaseId);
return {
tag,
version,
diff --git a/packages/repository-automation/test/release.test.mjs b/packages/repository-automation/test/release.test.mjs
index d156518c4..632b23774 100644
--- a/packages/repository-automation/test/release.test.mjs
+++ b/packages/repository-automation/test/release.test.mjs
@@ -112,6 +112,9 @@ describe("release source and metadata", () => {
["1.2.3", "latest", false],
["0.13.2-1", "latest", false],
["0.13.2-2", "latest", false],
+ ["0.13.3-0", "latest", false],
+ ["0.13.3-0-1", "latest", false],
+ ["0.13.3-cpa.1", "next", true],
["1.2.3-test.1", "test", true],
["1.2.3-beta.1", "next", true],
["1.2.3+build.1", "latest", false],
From 2cc7102944dcdff7fe30b077876dac992f461fd2 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 02:47:09 +0900
Subject: [PATCH 11/98] feat: add Host-owned external Harness live voice
Route native Desktop realtime start/stop for external Threads through
Host, report externalSandbox, and acknowledge thread/settings/update
without applying Harness permissions. Carry the Composer Harness
selection into overlay voice launch, isolate leftover overlay
notifications, and install a Grok-only Desktop session rule.
---
.../harness-plugin-architecture.md | 1 +
docs/index.md | 2 +-
.../operations/cpa-live-voice-desktop-hook.md | 102 +++++++-
package-lock.json | 3 +-
.../src/renderer-draft-prewarm-runtime.ts | 5 +
packages/host-runtime/package.json | 1 +
.../host-runtime/scripts/build-release.mjs | 1 +
packages/host-runtime/src/app-server-host.ts | 105 ++++++--
.../src/codexhost-desktop-rule.ts | 167 +++++++++++++
.../src/external-voice-gateway.ts | 198 +++++++++++++++
.../src/external-voice-results.ts | 79 ++++++
packages/host-runtime/src/external-voice.ts | 232 +++++++++++++++++
packages/host-runtime/src/index.ts | 1 +
packages/host-runtime/src/run-host-runtime.ts | 34 ++-
.../test/app-server-host-fixture.ts | 4 +
.../app-server-host.external-sandbox.test.ts | 101 ++++++++
.../app-server-host.external-voice.test.ts | 134 ++++++++++
...p-server-host.official-passthrough.test.ts | 17 ++
.../test/codexhost-desktop-rule.test.ts | 117 +++++++++
.../test/external-voice-gateway.test.ts | 200 +++++++++++++++
.../test/external-voice-lifecycle.test.ts | 98 ++++++++
.../src/cpa-live-voice-entry.ts | 2 +
.../src/production-entry.ts | 2 +
.../src/renderer-external-voice-launch.ts | 164 ++++++++++++
.../src/renderer-overlay-thread-isolation.ts | 113 +++++++++
.../renderer-external-voice-launch.test.ts | 236 ++++++++++++++++++
.../test/renderer-host-routing.test.ts | 27 ++
.../renderer-overlay-thread-isolation.test.ts | 143 +++++++++++
28 files changed, 2256 insertions(+), 33 deletions(-)
create mode 100644 packages/host-runtime/src/codexhost-desktop-rule.ts
create mode 100644 packages/host-runtime/src/external-voice-gateway.ts
create mode 100644 packages/host-runtime/src/external-voice-results.ts
create mode 100644 packages/host-runtime/src/external-voice.ts
create mode 100644 packages/host-runtime/test/app-server-host.external-sandbox.test.ts
create mode 100644 packages/host-runtime/test/app-server-host.external-voice.test.ts
create mode 100644 packages/host-runtime/test/codexhost-desktop-rule.test.ts
create mode 100644 packages/host-runtime/test/external-voice-gateway.test.ts
create mode 100644 packages/host-runtime/test/external-voice-lifecycle.test.ts
create mode 100644 packages/renderer-extension/src/renderer-external-voice-launch.ts
create mode 100644 packages/renderer-extension/src/renderer-overlay-thread-isolation.ts
create mode 100644 packages/renderer-extension/test/renderer-external-voice-launch.test.ts
create mode 100644 packages/renderer-extension/test/renderer-overlay-thread-isolation.test.ts
diff --git a/docs/architecture/harness-plugin-architecture.md b/docs/architecture/harness-plugin-architecture.md
index b60fac232..7dcb9ebd3 100644
--- a/docs/architecture/harness-plugin-architecture.md
+++ b/docs/architecture/harness-plugin-architecture.md
@@ -107,6 +107,7 @@ Codex Desktop 协议、官方 app-server、Renderer 兼容绑定仍是 codexhost
- [`run-host-runtime.ts`](../../packages/host-runtime/src/run-host-runtime.ts) 显式预取 Claude/Antigravity 目录。
- [`harness-broker`](../../packages/harness-broker/src/protocol.ts) 的描述、客户端与服务端实际限定 Claude Code,不是已经通用的插件进程协议。
- [`delegation-skill.ts`](../../packages/host-runtime/src/delegation-skill.ts) 包含 `.claude` 安装位置。
+- [`codexhost-desktop-rule.ts`](../../packages/host-runtime/src/codexhost-desktop-rule.ts) 把 Codex Desktop / CodexHost 会话规则安装到 `~/.grok/rules`。
- 基线中的 [`remote-host-install.ts`](../../packages/host-runtime/src/remote-host-install.ts) 曾固化 `claudeCommand`;该安装与启动特例现已移除,Harness 可执行文件发现仍由 Adapter 负责。
- 发布脚本维护各原生 SDK 与许可证。
diff --git a/docs/index.md b/docs/index.md
index bee0a635a..1bd222eea 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -107,7 +107,7 @@
| [`product/codex-native-account-switching-design.md`](product/codex-native-account-switching-design.md) | 移除 Codex 多账号切换后的只读边界;修改 Codex 认证或账号路由时阅读。 |
| [`architecture/renderer-settings-styling.md`](architecture/renderer-settings-styling.md) | 设置页 Tailwind CSS 使用边界、构建方式与编写规则;新增或改版设置页界面时阅读。 |
| [`operations/codex-desktop-upgrade-diagnosis-playbook.md`](operations/codex-desktop-upgrade-diagnosis-playbook.md) | Desktop 更新后 Renderer、Bridge、Agent 和 Model 异常,以及输入性能的诊断流程;兼容性与交互性能回归时阅读。 |
-| [`operations/cpa-live-voice-desktop-hook.md`](operations/cpa-live-voice-desktop-hook.md) | Live-voice `clientOwnsCall` override (default on, Desktop 26.1007.30912 only); Settings → General switch, `CODEXHOST_CPA_LIVE_VOICE=0`, and `config.toml` realtime base URLs. |
+| [`operations/cpa-live-voice-desktop-hook.md`](operations/cpa-live-voice-desktop-hook.md) | Audited native call-ownership hook and external Harness voice prototype: new-chat selection handoff, `externalSandbox` ownership, shared task routing, configured gateway, lifecycle limits, Harness Desktop session rules, and unchanged official Codex passthrough. |
## 平台、进程与远程运行
diff --git a/docs/operations/cpa-live-voice-desktop-hook.md b/docs/operations/cpa-live-voice-desktop-hook.md
index 19e797509..8c4996b1f 100644
--- a/docs/operations/cpa-live-voice-desktop-hook.md
+++ b/docs/operations/cpa-live-voice-desktop-hook.md
@@ -23,7 +23,7 @@ The hook forces Statsig `checkGate("206132468")` to false on every client regist
1. Unless `CODEXHOST_CPA_LIVE_VOICE=0`, the Controller prefixes the production Renderer source with a frozen `globalThis.__codexhostCpaLiveVoiceHookV1` record (audited Desktop version and `app-initial` asset name only).
2. `Page.addScriptToEvaluateOnNewDocument` plus immediate `Runtime.evaluate` inject the Host Renderer into the primary `app://-/index.html` page.
-3. Voice starts on the avatar overlay (`app://-/index.html?initialRoute=/avatar-overlay`). That page gets a **hook-only** bundle (`cpa-live-voice-hook.js`), not the Host Renderer binding. The overlay loads the same audited `app-initial` asset in its own JavaScript realm with its own Statsig client.
+3. Voice starts on the avatar overlay (`app://-/index.html?initialRoute=/avatar-overlay`). That page gets a **hook-only** bundle (`cpa-live-voice-hook.js`), not the Host Renderer binding. The overlay loads the same audited `app-initial` asset in its own JavaScript realm with its own Statsig client. The overlay bundle also drops Host `thread/started` and turn/item notifications unless that overlay currently owns the realtime Thread, so a leftover overlay does not upsert CLI-created Threads or rebind the same Turn into the main chat.
4. The Renderer hook no-ops unless the exact audited `app://-/assets/app-initial-6350d04672d0.js` asset is present (performance entries, script src, or link href, including relative URLs). Unknown assets fail closed; matching text in a query string or source-map filename is insufficient.
5. On a matching page it polls for up to 60 seconds until a Statsig client is registered, then wraps that client's `checkGate`. The gate is read when voice starts, so the wrap only has to land before the first voice click.
@@ -44,3 +44,103 @@ Audited only for Desktop **26.1007.30912** (primary bundle `app-initial-6350d046
- Do not quit or replace the working Voice copy until the CodexHost path proves `POST /v1/realtime/calls` 201.
- Do not put call IDs, SDP, or auth material in docs, git, or command output.
- Do not `git push` this branch or open an upstream PR unless explicitly requested.
+
+## External Harness voice (local development build)
+
+New-chat voice launches use the primary Composer's current Harness selection.
+On the audited Desktop build, the primary Renderer imports the already-loaded
+browser module `app-shared-8bd9d0399038.js` and wraps only its native
+`realtimeVoiceRuntime` service reference (export `fN`). Its RPC stubs themselves
+are immutable. For `composer_button_new_thread`, the wrapper snapshots the
+matching Host's selected transport carrier into
+`activeCollaborationMode.settings.model`. The separate voice overlay forwards
+that collaboration mode into native Thread creation, which uses its model for
+`thread/start`. This prevents a new Grok voice chat from silently becoming an
+official Codex chat. No browser message transport or ASAR code is patched.
+
+The wrapper requires both exact audited frontend assets. It imports only after
+they are present and waits up to 60 seconds for the native services record.
+Existing-Thread launches, avatar/global launches, official Codex selection,
+ambiguous Composers and mismatched/disconnected Host owners pass through.
+Selection is captured before launch, so changing the draft later cannot retarget
+an in-flight voice chat. This is generic to the selected Harness, not Grok-specific.
+The new-chat correction has focused routing/forwarding tests; installed native
+button acceptance is still required. Earlier installed acceptance exposed the
+missing new-chat selection handoff despite successful isolated transport trials.
+
+External `thread/start`, `thread/resume`, and `thread/fork` report
+`sandbox: { type: "externalSandbox", networkAccess: "enabled" }`. Codex does not
+enforce a sandbox on those Threads; Harness permission modes stay authoritative.
+Native voice overlay startup also sends `thread/settings/update` with
+`summary: "detailed"` when concurrent reasoning summaries are enabled. External
+`thread/settings/update` returns an empty success without applying Codex
+sandbox or permission changes; Harness permission modes stay authoritative.
+Official Codex sandbox traffic is unchanged.
+
+External Threads now have a separate Host-owned handler for native WebRTC audio
+`thread/realtime/start` and `thread/realtime/stop`. Official Codex Threads continue
+through the native app-server unchanged, including their existing gateway routing.
+The handler uses the selected external Thread's existing HarnessSession and normal
+Turn/approval projection; it does not start a second agent or import a concrete Adapter.
+
+The transport targets CPA's frameless V3 client-delegation protocol. It posts the
+Desktop SDP and client-delegation session to the configured realtime call base,
+returns the answer through native `started`/`sdp` notifications, and joins the
+configured WebSocket base at `/live/{callId}`. Both configured bases must use HTTPS
+and share an origin. Authentication uses the selected configured model provider's
+`experimental_bearer_token` or `env_key`; OAuth from `auth.json` is never copied.
+The Host reads `CODEX_HOME/config.toml` only and does not mutate gateway settings.
+Profile/command-line credential overrides are not resolved by this prototype.
+
+V3 `delegation.created` task text goes to that same external Thread. Only completed
+agent-message results are returned as speakable context; private reasoning and tool
+output are excluded. Delegation IDs are deduplicated within a call. Transcripts are
+projected through native notifications. Closing the call releases voice resources
+and suppresses late speech, but does not cancel a running agent task: explicit task
+cancellation remains the existing `turn/interrupt` operation.
+
+The speech service is instructed to delegate every question and task, including
+simple arithmetic; it supplies speech rather than owning task reasoning.
+The prototype defaults to Cantonese and the protocol's `cove` voice; supported V3
+voices can be requested. Text/audio append transports, ExistingCall attachment,
+overlapping-task steering, newer public GPT-Live event dialects, remote-host media
+topology, and seamless call recovery are not supported. A rejected control connection
+or session-initialization timeout emits error/closed notifications and performs
+bounded hangup cleanup. Retry is explicit and never resubmits an agent task.
+If the gateway returns success without a usable call identity, the Host rejects
+startup and releases the response body, but cannot identify a safe remote hangup
+target. Remote cleanup is then unconfirmed. Network failures during hangup are
+also reported as `cleanup_unconfirmed`; a closed local overlay is not proof of
+remote cleanup.
+
+Verification includes an isolated real native call through New API/CPA (creation,
+WebRTC, data channel, session initialization, sideband, and hangup), a real Host
+trial using the public Grok plugin and an existing microphone clip, and Host
+fixtures for multiple Harness IDs and official passthrough. The Host trial
+observed a completed Grok Turn and the returned spoken-answer transcript, with
+no official app-server request; the final trial confirmed hangup with HTTP 404
+(call already gone). One preceding trial returned the answer but did not confirm
+hangup. These are isolated headless-page trials, not installed Desktop button
+acceptance or proof that every plugin is voice-compatible. Continuous conversation,
+speech interruption, task cancellation and approvals need attended acceptance.
+
+## Harness session rules (Grok home rule)
+
+Host Runtime installs `codexhost-desktop.md` into `~/.grok/rules/` only. It does
+not write `~/.claude/rules/`. A previously managed Claude copy is removed when
+its digest still matches a Host-owned version; a user-edited Claude copy is
+preserved.
+
+Grok home rules are always scanned. At the first user turn the agent runs a
+secret-free `CODEXHOST_THREAD_ID` set/unset check. `set` means this is a
+CodexHost external Thread, including a Desktop chat whose cwd is a real
+repository. A Playground `realtime-voice-chat*` cwd also activates the rule and
+asks for a new Thread in the real repo. `unset` outside that Playground cwd
+leaves the six Desktop rules inactive. The agent never prints tokens, endpoints,
+or runtime descriptors.
+
+The rule covers composer Model selection (including that `flash-max` is Gemini
+Flash), starting in the real repo, voice versus text tool work, per-Harness MCP
+and skills, Codex Desktop chrome / Markdown View, and official Codex live voice
+versus Host-owned external `thread/realtime/*`. A user-edited Grok copy is
+preserved.
diff --git a/package-lock.json b/package-lock.json
index 7d56234ce..ea8a0d865 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5899,7 +5899,8 @@
"@codexhost/shared-contracts": "0.0.0",
"@codexhost/update-manager": "0.0.0",
"@deepseek-ai/cordis": "4.0.4",
- "ws": "8.21.3"
+ "ws": "8.21.3",
+ "smol-toml": "^1.8.0"
}
},
"packages/mapping-store": {
diff --git a/packages/desktop-control/src/renderer-draft-prewarm-runtime.ts b/packages/desktop-control/src/renderer-draft-prewarm-runtime.ts
index 0eff6dd19..cf383c3c5 100644
--- a/packages/desktop-control/src/renderer-draft-prewarm-runtime.ts
+++ b/packages/desktop-control/src/renderer-draft-prewarm-runtime.ts
@@ -689,6 +689,11 @@ export function createDraftPrewarmPolicyBridge(
throw new Error("Renderer request manager is retired");
return manager;
},
+ selectedModel(): string | null {
+ if (disposed || (isCurrentManager && !isCurrentManager()))
+ throw new Error("Renderer request manager is retired");
+ return selectedModel;
+ },
select(model: string | null): boolean {
if (disposed || (isCurrentManager && !isCurrentManager()))
throw new Error("Renderer request manager is retired");
diff --git a/packages/host-runtime/package.json b/packages/host-runtime/package.json
index e979728cf..2ed4432b2 100644
--- a/packages/host-runtime/package.json
+++ b/packages/host-runtime/package.json
@@ -26,6 +26,7 @@
"@codexhost/shared-contracts": "0.0.0",
"@codexhost/update-manager": "0.0.0",
"@deepseek-ai/cordis": "4.0.4",
+ "smol-toml": "^1.8.0",
"ws": "8.21.3"
}
}
diff --git a/packages/host-runtime/scripts/build-release.mjs b/packages/host-runtime/scripts/build-release.mjs
index 4a1518c0b..a227dc3e1 100644
--- a/packages/host-runtime/scripts/build-release.mjs
+++ b/packages/host-runtime/scripts/build-release.mjs
@@ -20,6 +20,7 @@ const allowedRuntimePackages = new Set([
"@deepseek-ai/cordis",
"@deepseek-ai/cosmokit",
"diff",
+ "smol-toml",
"ws",
"zod",
]);
diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts
index 4db3b8d7f..58310f6a0 100644
--- a/packages/host-runtime/src/app-server-host.ts
+++ b/packages/host-runtime/src/app-server-host.ts
@@ -14,6 +14,12 @@ import {
import { requestDesktopRemoteConnections } from "@codexhost/desktop-control";
import type { RuntimeMaintenance } from "./runtime-maintenance.js";
import { ExternalThreadPrewarms } from "./external-thread-prewarms.js";
+import { ExternalVoice } from "./external-voice.js";
+import { ExternalVoiceResults } from "./external-voice-results.js";
+import {
+ configuredExternalVoiceGateway,
+ type ExternalVoiceCallFactory,
+} from "./external-voice-gateway.js";
import { HarnessLaunchSettingsStore } from "@codexhost/harness-plugin-files";
import type { SharedThreadBridge } from "./shared-thread-bridge.js";
import { HARNESS_INSTALLATION_METHOD } from "@codexhost/shared-contracts";
@@ -349,6 +355,8 @@ export interface AppServerHostOptions {
modelPrices?: ModelPriceCatalog;
/** Machine-wide usage statistics; one per runtime, read from the loaded Harness plugins. */
usageStatistics?: UsageStatistics;
+ /** External Harness voice only; official Codex realtime is always native passthrough. */
+ createExternalVoiceCall?: ExternalVoiceCallFactory;
}
interface TurnProjectionGate {
@@ -553,17 +561,9 @@ function requestText(params: JsonObject): string {
return text;
}
-function sandboxResult(params: JsonObject): JsonObject {
- const sandbox = params.sandbox;
- if (sandbox === "read-only") return { type: "readOnly", networkAccess: false };
- if (sandbox === "danger-full-access") return { type: "dangerFullAccess" };
- return {
- type: "workspaceWrite",
- networkAccess: false,
- writableRoots: [],
- excludeTmpdirEnvVar: false,
- excludeSlashTmp: false,
- };
+/** Native protocol type: Codex does not enforce a sandbox on external Threads. */
+function sandboxResult(): JsonObject {
+ return { type: "externalSandbox", networkAccess: "enabled" };
}
function turnProjectionGate(): TurnProjectionGate {
@@ -619,6 +619,8 @@ export class AppServerHost {
readonly #executionHosts = new WeakMap();
readonly #externalPrewarms = new ExternalThreadPrewarms();
readonly #externalSteering = new ExternalTurnSteering();
+ readonly #externalVoice: ExternalVoice;
+ readonly #externalVoiceResults = new ExternalVoiceResults();
readonly #liveCommandCache = new LiveCommandCatalogCache();
readonly #modelPrices: ModelPriceCatalog;
readonly #observedSessionUsage: ObservedSessionUsage;
@@ -681,6 +683,12 @@ export class AppServerHost {
return this.#takeConsoleReply(value);
});
const environment = this.#options.environment ?? process.env;
+ this.#externalVoice = new ExternalVoice({
+ createCall: options.createExternalVoiceCall ?? configuredExternalVoiceGateway(environment),
+ notify: (method, params) => this.#writer.json({ method, params }),
+ diagnose: () => this.#diagnose("External voice cleanup was not confirmed"),
+ runTask: (threadId, text, signal) => this.#runExternalVoiceTask(threadId, text, signal),
+ });
this.#launchSettings = new HarnessLaunchSettingsStore(
this.#options.pluginContext?.environment ?? environment,
);
@@ -775,6 +783,7 @@ export class AppServerHost {
idleRelease: {
queue: this.#desktopRequests,
onClosed: async (thread) => {
+ await this.#externalVoice.stop(thread.id);
for (const pending of [...this.#pendingDesktopApprovals.values()]) {
if (pending.thread === thread)
await this.#resolveDesktopApproval(pending.interaction.interactionId);
@@ -785,6 +794,7 @@ export class AppServerHost {
}
},
canRelease: (thread) =>
+ !this.#externalVoice.has(thread.id) &&
!this.#hasRunningSubagents(thread.id) &&
!thread.session.hasBackgroundWork?.() &&
!this.#externalSteering.hasPending(thread.id) &&
@@ -857,6 +867,7 @@ export class AppServerHost {
close(): void {
if (this.#closeRequested) return;
this.#closeRequested = true;
+ void this.#externalVoice.close();
this.#options.sharedThreads?.close();
this.#externalRuntime.idleRelease.disable();
this.#pluginLoadAbort.abort();
@@ -999,6 +1010,7 @@ export class AppServerHost {
return this.#closeRequested ? 0 : 1;
} finally {
this.#options.sharedThreads?.close();
+ await this.#externalVoice.close();
this.#pluginLoadAbort.abort();
// Stop replacement waiters before waiting for their tracked Host operations.
this.#externalSteering.close();
@@ -2082,6 +2094,22 @@ export class AppServerHost {
return;
}
}
+ if (
+ request.method.startsWith("thread/realtime/") &&
+ isRecord(request.params) &&
+ typeof request.params.threadId === "string"
+ ) {
+ const resolution = await this.#resolveExternalThread(request.params.threadId);
+ if (await this.#writeResolutionError(request, resolution)) return;
+ if (resolution.kind === "external") {
+ this.#dispatchDesktopReply(
+ request,
+ () => this.#handleExternalVoiceRequest(request, resolution.thread),
+ resolution.thread.id,
+ );
+ return;
+ }
+ }
if (
request.method.startsWith("thread/") &&
!EXPLICIT_EXTERNAL_THREAD_METHODS.has(request.method) &&
@@ -4045,7 +4073,7 @@ export class AppServerHost {
approvalPolicy:
typeof params.approvalPolicy === "string" ? params.approvalPolicy : "never",
approvalsReviewer: "user",
- sandbox: sandboxResult(params),
+ sandbox: sandboxResult(),
reasoningEffort: "medium",
serviceTier: "flex",
multiAgentMode: "explicitRequestOnly",
@@ -4170,9 +4198,6 @@ export class AppServerHost {
await this.#writer.json(rpcError(request, result.error.code, result.error.message));
return;
}
- const params: JsonObject = {
- ...(fork.sandbox ? { sandbox: fork.sandbox } : {}),
- };
await this.#writer.json(
rpcEnvelope(request, {
result: threadForkResult(result.responseThread, {
@@ -4182,7 +4207,7 @@ export class AppServerHost {
? { runtimeWorkspaceRoots: fork.runtimeWorkspaceRoots }
: {}),
...(fork.approvalPolicy ? { approvalPolicy: fork.approvalPolicy } : {}),
- sandbox: sandboxResult(params),
+ sandbox: sandboxResult(),
...(fork.serviceTier ? { serviceTier: fork.serviceTier } : {}),
}),
}),
@@ -4287,6 +4312,7 @@ export class AppServerHost {
location: Extract,
): Promise {
const thread = location.thread;
+ await this.#externalVoice.stop(location.record.hostThreadId);
try {
await this.#repository.removeThread(location.record.hostThreadId);
} catch {
@@ -4430,7 +4456,7 @@ export class AppServerHost {
? params.runtimeWorkspaceRoots.filter((value): value is string => typeof value === "string")
: [],
approvalPolicy: typeof params.approvalPolicy === "string" ? params.approvalPolicy : "never",
- sandbox: sandboxResult(params),
+ sandbox: sandboxResult(),
...(typeof params.serviceTier === "string" ? { serviceTier: params.serviceTier } : {}),
});
try {
@@ -4555,6 +4581,49 @@ export class AppServerHost {
}
}
+ async #handleExternalVoiceRequest(
+ request: JsonRpcRequest,
+ thread: ExternalThread,
+ ): Promise {
+ try {
+ if (request.method === "thread/realtime/start") {
+ await this.#externalVoice.start(thread.id, requestObject(request));
+ } else if (request.method === "thread/realtime/stop") {
+ await this.#externalVoice.stop(thread.id);
+ } else {
+ await this.#writer.json(
+ rpcError(request, -32076, "External voice uses the native WebRTC audio transport"),
+ );
+ return;
+ }
+ await this.#writer.json(rpcEnvelope(request, { result: {} }));
+ } catch {
+ await this.#writer.json(
+ rpcError(request, -32076, "External voice could not complete this call request"),
+ );
+ }
+ }
+
+ async #runExternalVoiceTask(
+ threadId: string,
+ text: string,
+ signal: AbortSignal,
+ ): Promise {
+ let answer: Promise | undefined;
+ await this.#desktopRequests.run(threadId, async () => {
+ if (signal.aborted) throw new Error("Voice session ended");
+ const thread = this.#externalRuntime.get(threadId);
+ if (!thread || this.#closeRequested) throw new Error("External chat is unavailable");
+ const started = await this.#beginExternalInputTurn(thread, text);
+ answer = this.#externalVoiceResults.watch(threadId, started.turnId, signal);
+ // Attach rejection handling before admitting output; the caller awaits after queue release.
+ void answer.catch(() => undefined);
+ started.gate.resolve();
+ });
+ if (!answer) throw new Error("External voice task did not start");
+ return answer;
+ }
+
async #startExternalTurn(request: JsonRpcRequest, thread: ExternalThread): Promise {
if (this.#externalThreadBusy(thread) || this.#pendingExternalCommandRequests.has(thread.id)) {
await this.#writer.json(
@@ -5026,6 +5095,7 @@ export class AppServerHost {
return;
}
if (event.type === "session.faulted") {
+ this.#externalVoiceResults.observe(thread.id, event);
this.#externalSteering.fault(thread.id, new Error(event.error.message));
thread.stateObserver.fault(new Error(event.error.message));
this.#externalRuntime.markSessionUnavailable(thread);
@@ -5161,6 +5231,7 @@ export class AppServerHost {
thread.projectedTurns.delete(event.turnId);
}
for (const message of result.messages) await this.#writer.json(message);
+ this.#externalVoiceResults.observe(thread.id, event);
if (event.type === "turn.completed") {
await this.#setThreadStatus(
thread,
diff --git a/packages/host-runtime/src/codexhost-desktop-rule.ts b/packages/host-runtime/src/codexhost-desktop-rule.ts
new file mode 100644
index 000000000..bea95157a
--- /dev/null
+++ b/packages/host-runtime/src/codexhost-desktop-rule.ts
@@ -0,0 +1,167 @@
+import { createHash, randomUUID } from "node:crypto";
+import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+const RULE_VERSION = 3;
+const RULE_FILE = "codexhost-desktop.md";
+const PREVIOUS_MANAGED_DIGESTS: readonly string[] = [
+ "5add2d2c7ea03ecbb3552b94e47da7a755909e63f30714878a5609a4e272526f",
+ "9d8d2762b5f90e6f10a6571651b3461dec882bd45b40f0f0debc432f4833301b",
+];
+
+export const CODEXHOST_DESKTOP_RULE = `
+# Codex Desktop / CodexHost
+
+CodexHost installs this file into Grok home rules. Orca and Grok TUI sessions may also load it.
+
+At the first user turn, if you have a shell, run this once and print only \`set\` or \`unset\`:
+
+\`\`\`sh
+if test -n "\${CODEXHOST_THREAD_ID-}"; then echo set; else echo unset; fi
+\`\`\`
+
+- \`set\`: this is a CodexHost external Thread, including Codex Desktop. Apply the session rules even when cwd is a real repository such as \`codex-host\` or the wiki.
+- Working directory is a Playground folder named \`realtime-voice-chat\` or \`realtime-voice-chat-*\`: apply the session rules and ask the user to start a new Thread in the real repo.
+- \`unset\` and not a Playground voice cwd: leave the session rules inactive.
+
+Never print \`CODEXHOST_THREAD_ID\`, \`CODEXHOST_RUNTIME_TOKEN\`, \`CODEXHOST_RUNTIME_ENDPOINT\`, or other runtime descriptors. Do not invent other host signals.
+
+## Session rules
+
+1. **Model.** The Desktop composer Model is this Harness's Model. Pick that Harness's real Model before starting voice. For Grok, that is grok-4.6 or grok-4.7. \`flash-max\` is a Gemini Flash Model.
+
+2. **Working directory.** Start the chat in the real repository (\`codex-host\` or the wiki). A Playground \`realtime-voice-chat*\` folder is a scratch cwd. Ask the user to start a new Thread in the real repo.
+
+3. **Voice vs tools.** Desktop voice is gpt-live speech plus delegated Harness tasks on this same Thread. Do wiki, MCP, and file-heavy work as text in this Thread.
+
+4. **Harness-owned tools.** This session's MCP servers and skills belong to this Harness. SkillWiki on Grok is Grok's config, not Claude, Pi, or Codex.
+
+5. **Desktop chrome.** The window is Codex Desktop. Markdown View preview is a Codex file tab (View preview vs View source). \`file://\` browser tabs stay raw.
+
+6. **Voice routes.** Official Codex live voice (New API/CPA) is a separate route from Host-owned external \`thread/realtime/*\`. This external Thread uses the Host-owned route.
+
+Keep Grok Build tools, MCP, and rules inside the native session. An \`AGENT_TOOLS_AND_RULES.md\` dump in the cwd is unnecessary.
+`;
+
+const CURRENT_DIGEST = createHash("sha256").update(CODEXHOST_DESKTOP_RULE).digest("hex");
+
+function digest(value: string): string {
+ return createHash("sha256").update(value).digest("hex");
+}
+
+function managedVersion(value: string): number | null {
+ const match = /codexhost-desktop-rule:\s*(\d+)/u.exec(value);
+ return match ? Number(match[1]) : null;
+}
+
+function grokDestination(home: string): string {
+ return path.join(home, ".grok", "rules", RULE_FILE);
+}
+
+function claudeDestination(home: string): string {
+ return path.join(home, ".claude", "rules", RULE_FILE);
+}
+
+async function readOptional(filePath: string): Promise {
+ try {
+ return await readFile(filePath, "utf8");
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
+ throw error;
+ }
+}
+
+async function atomicWrite(filePath: string, content: string): Promise {
+ await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
+ const temporaryPath = path.join(path.dirname(filePath), `.${RULE_FILE}.${randomUUID()}.tmp`);
+ const handle = await open(temporaryPath, "wx", 0o600);
+ try {
+ await handle.writeFile(content, "utf8");
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+ try {
+ await rename(temporaryPath, filePath);
+ } finally {
+ await rm(temporaryPath, { force: true });
+ }
+}
+
+export type DesktopRuleInstallStatus = "installed" | "updated" | "current" | "conflict" | "removed";
+
+export interface DesktopRuleInstallResult {
+ path: string;
+ status: DesktopRuleInstallStatus;
+ version: number | null;
+ digest: string | null;
+}
+
+export async function installCodexhostDesktopRules(
+ input: {
+ homeDirectory?: string;
+ previousManagedDigests?: readonly string[];
+ } = {},
+): Promise {
+ const home = input.homeDirectory ?? os.homedir();
+ const knownDigests = new Set([
+ CURRENT_DIGEST,
+ ...PREVIOUS_MANAGED_DIGESTS,
+ ...(input.previousManagedDigests ?? []),
+ ]);
+ const results: DesktopRuleInstallResult[] = [];
+ const destination = grokDestination(home);
+ const current = await readOptional(destination);
+ if (current === CODEXHOST_DESKTOP_RULE) {
+ results.push({
+ path: destination,
+ status: "current",
+ version: RULE_VERSION,
+ digest: CURRENT_DIGEST,
+ });
+ } else if (current !== null) {
+ const currentDigest = digest(current);
+ const version = managedVersion(current);
+ if (!knownDigests.has(currentDigest)) {
+ results.push({ path: destination, status: "conflict", version, digest: currentDigest });
+ } else {
+ await atomicWrite(destination, CODEXHOST_DESKTOP_RULE);
+ results.push({
+ path: destination,
+ status: "updated",
+ version: RULE_VERSION,
+ digest: CURRENT_DIGEST,
+ });
+ }
+ } else {
+ await atomicWrite(destination, CODEXHOST_DESKTOP_RULE);
+ results.push({
+ path: destination,
+ status: "installed",
+ version: RULE_VERSION,
+ digest: CURRENT_DIGEST,
+ });
+ }
+ const claude = claudeDestination(home);
+ const claudeCurrent = await readOptional(claude);
+ if (claudeCurrent !== null) {
+ const claudeDigest = digest(claudeCurrent);
+ const version = managedVersion(claudeCurrent);
+ if (knownDigests.has(claudeDigest) || claudeCurrent === CODEXHOST_DESKTOP_RULE) {
+ await rm(claude, { force: true });
+ results.push({ path: claude, status: "removed", version, digest: claudeDigest });
+ } else {
+ results.push({ path: claude, status: "conflict", version, digest: claudeDigest });
+ }
+ }
+ for (const result of results) {
+ if (result.status === "conflict" || result.status === "removed") continue;
+ const source = await readFile(result.path, "utf8");
+ const metadata = await stat(result.path);
+ if (!metadata.isFile() || source !== CODEXHOST_DESKTOP_RULE) {
+ throw new Error(`Codex Desktop rule verification failed: ${result.path}`);
+ }
+ }
+ return results;
+}
diff --git a/packages/host-runtime/src/external-voice-gateway.ts b/packages/host-runtime/src/external-voice-gateway.ts
new file mode 100644
index 000000000..931b0e4ce
--- /dev/null
+++ b/packages/host-runtime/src/external-voice-gateway.ts
@@ -0,0 +1,198 @@
+import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import path from "node:path";
+import { parse } from "smol-toml";
+import WebSocket from "ws";
+import type { JsonObject } from "@codexhost/protocol-core";
+
+export interface ExternalVoiceCall {
+ sdp: string;
+ /** Opens control only after the Host has sent the SDP answer to Desktop. */
+ connect(): Promise;
+ send(event: JsonObject): void;
+ close(): Promise;
+}
+
+export type ExternalVoiceCallFactory = (input: {
+ sdp: string;
+ voice?: string;
+ signal: AbortSignal;
+ onEvent(event: JsonObject): void;
+ onError(): void;
+}) => Promise;
+
+function object(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Read only the configured gateway; never borrow OAuth from auth.json. */
+export function configuredExternalVoiceGateway(
+ environment: NodeJS.ProcessEnv,
+): ExternalVoiceCallFactory {
+ return async (input) => {
+ let config: Record;
+ try {
+ config = parse(
+ await readFile(
+ path.join(environment.CODEX_HOME ?? path.join(homedir(), ".codex"), "config.toml"),
+ "utf8",
+ ),
+ );
+ } catch {
+ throw new Error("External voice gateway configuration is unavailable");
+ }
+ const providers = config.model_providers;
+ const provider =
+ object(providers) && typeof config.model_provider === "string"
+ ? providers[config.model_provider]
+ : undefined;
+ const token = object(provider)
+ ? typeof provider.experimental_bearer_token === "string"
+ ? provider.experimental_bearer_token
+ : typeof provider.env_key === "string"
+ ? environment[provider.env_key]
+ : undefined
+ : undefined;
+ const base = config.experimental_realtime_webrtc_call_base_url;
+ const sidebandBase = config.experimental_realtime_ws_base_url ?? base;
+ if (typeof base !== "string" || typeof sidebandBase !== "string" || !token) {
+ throw new Error("External voice requires a configured realtime gateway and API credential");
+ }
+ let url: URL;
+ let sideband: URL;
+ try {
+ url = new URL(base);
+ sideband = new URL(sidebandBase);
+ } catch {
+ throw new Error("External voice gateway URL is invalid");
+ }
+ if (
+ url.protocol !== "https:" ||
+ sideband.protocol !== "https:" ||
+ url.origin !== sideband.origin ||
+ url.username ||
+ url.password ||
+ sideband.username ||
+ sideband.password ||
+ url.search ||
+ url.hash ||
+ sideband.search ||
+ sideband.hash
+ )
+ throw new Error("External voice requires matching HTTPS gateway origins");
+ const headers = { Authorization: `Bearer ${token}`, "OpenAI-Alpha": "quicksilver=v2" };
+ const form = new FormData();
+ form.append("sdp", input.sdp);
+ form.append(
+ "session",
+ JSON.stringify({
+ model:
+ typeof config.experimental_realtime_ws_model === "string"
+ ? config.experimental_realtime_ws_model
+ : "gpt-realtime",
+ instructions:
+ "Use natural Hong Kong Cantonese. Delegate every user question and task to the client, including simple arithmetic. " +
+ "The client agent owns all reasoning and task execution. Do not answer questions yourself or invent task results. " +
+ "Speak only verified client results; do not read private reasoning or machine identifiers.",
+ audio: { output: { voice: input.voice ?? "cove" } },
+ delegation: { type: "client", ack_filler: false },
+ }),
+ );
+ const response = await fetch(`${url.href.replace(/\/$/u, "")}/realtime/calls`, {
+ method: "POST",
+ headers,
+ body: form,
+ redirect: "error",
+ signal: AbortSignal.any([input.signal, AbortSignal.timeout(15_000)]),
+ });
+ if (!response.ok) throw new Error("External voice call creation failed");
+ const location = response.headers.get("location");
+ let callId: string | undefined;
+ try {
+ if (location)
+ callId = new URL(location, url).pathname.match(
+ /\/(?:calls|live)\/([A-Za-z0-9_-]{1,128})$/u,
+ )?.[1];
+ } catch {
+ // Without a valid identity there is no safe remote hangup target.
+ }
+ if (!callId) {
+ await response.body?.cancel().catch(() => undefined);
+ throw new Error("External voice call identity is unavailable; remote cleanup is unconfirmed");
+ }
+ let socket: WebSocket | undefined;
+ let closed = false;
+ let closeTask: Promise | undefined;
+ const close = (): Promise =>
+ (closeTask ??= (async () => {
+ closed = true;
+ socket?.terminate();
+ const result = await fetch(
+ `${url.href.replace(/\/$/u, "")}/realtime/calls/${callId}/hangup`,
+ {
+ method: "POST",
+ headers,
+ redirect: "error",
+ signal: AbortSignal.timeout(10_000),
+ },
+ );
+ if (!result.ok && result.status !== 404)
+ throw new Error("External voice hangup was not confirmed");
+ })());
+ try {
+ const sdp = await response.text();
+ if (input.signal.aborted) {
+ await close();
+ throw new Error("External voice start cancelled");
+ }
+ return {
+ sdp,
+ async connect() {
+ if (closed || input.signal.aborted) throw new Error("External voice call is closed");
+ sideband.protocol = "wss:";
+ sideband.pathname = `${sideband.pathname.replace(/\/$/u, "")}/live/${callId}`;
+ const options: WebSocket.ClientOptions & { autoSelectFamily: boolean } = {
+ headers,
+ autoSelectFamily: true,
+ handshakeTimeout: 12_000,
+ maxPayload: 1 << 20,
+ };
+ const connection = new WebSocket(sideband, options);
+ socket = connection;
+ connection.on("error", () => {
+ if (!closed) input.onError();
+ });
+ connection.on("close", () => {
+ if (!closed) input.onError();
+ });
+ connection.on("message", (raw) => {
+ if (closed || input.signal.aborted) return;
+ try {
+ const event: unknown = JSON.parse(String(raw));
+ if (object(event) && typeof event.type === "string")
+ input.onEvent(event as JsonObject);
+ } catch {
+ input.onError();
+ }
+ });
+ await new Promise((resolve, reject) => {
+ connection.once("open", resolve);
+ connection.once("error", () =>
+ reject(new Error("External voice control connection failed")),
+ );
+ });
+ if (closed) throw new Error("External voice call is closed");
+ },
+ send(event) {
+ if (closed || socket?.readyState !== WebSocket.OPEN)
+ throw new Error("External voice control connection is unavailable");
+ socket.send(JSON.stringify(event));
+ },
+ close,
+ };
+ } catch {
+ await close();
+ throw new Error("External voice call startup failed");
+ }
+ };
+}
diff --git a/packages/host-runtime/src/external-voice-results.ts b/packages/host-runtime/src/external-voice-results.ts
new file mode 100644
index 000000000..4504b52b0
--- /dev/null
+++ b/packages/host-runtime/src/external-voice-results.ts
@@ -0,0 +1,79 @@
+import type { HostEvent } from "@codexhost/harness-adapter";
+
+/** Fan-out from the existing Host consumer; never consumes HarnessSession.outputs. */
+export class ExternalVoiceResults {
+ readonly #pending = new Map<
+ string,
+ {
+ threadId: string;
+ items: Map;
+ resolve(text: string): void;
+ reject(error: Error): void;
+ dispose(): void;
+ }
+ >();
+
+ watch(threadId: string, turnId: string, signal: AbortSignal): Promise {
+ if (signal.aborted) return Promise.reject(new Error("Voice session ended"));
+ return new Promise((resolve, reject) => {
+ const abort = (): void => {
+ this.#pending.delete(turnId);
+ reject(new Error("Voice session ended"));
+ };
+ signal.addEventListener("abort", abort, { once: true });
+ this.#pending.set(turnId, {
+ threadId,
+ items: new Map(),
+ resolve,
+ reject,
+ dispose: () => signal.removeEventListener("abort", abort),
+ });
+ });
+ }
+
+ observe(threadId: string, event: HostEvent): void {
+ if (event.type === "session.faulted") {
+ for (const [turnId, pending] of this.#pending) {
+ if (pending.threadId !== threadId) continue;
+ this.#pending.delete(turnId);
+ pending.dispose();
+ pending.reject(new Error("External agent session failed"));
+ }
+ return;
+ }
+ if (!("turnId" in event)) return;
+ const pending = this.#pending.get(event.turnId);
+ if (!pending || pending.threadId !== threadId) return;
+ if (
+ event.type === "item.completed" &&
+ event.snapshot.item.type === "agentMessage" &&
+ event.snapshot.outcome.status === "succeeded"
+ ) {
+ const item = event.snapshot.item;
+ pending.items.set(item.itemId, {
+ text: item.text,
+ ...(item.phase ? { phase: item.phase } : {}),
+ });
+ }
+ if (event.type !== "turn.completed") return;
+ this.#pending.delete(event.turnId);
+ pending.dispose();
+ if (event.outcome.status !== "succeeded") {
+ pending.reject(new Error("External agent task did not complete"));
+ return;
+ }
+ const items = [...pending.items.values()];
+ const finals = items.filter((item) => item.phase === "final_answer");
+ const text = (
+ finals.length ? finals : items.filter((item) => item.phase !== "commentary").slice(-1)
+ )
+ .map((item) => item.text)
+ .join("\n")
+ .trim();
+ if (!text) {
+ pending.reject(new Error("External agent returned no spoken result"));
+ return;
+ }
+ pending.resolve(text);
+ }
+}
diff --git a/packages/host-runtime/src/external-voice.ts b/packages/host-runtime/src/external-voice.ts
new file mode 100644
index 000000000..635dae153
--- /dev/null
+++ b/packages/host-runtime/src/external-voice.ts
@@ -0,0 +1,232 @@
+import { randomUUID } from "node:crypto";
+import type { JsonObject } from "@codexhost/protocol-core";
+import type { ExternalVoiceCall, ExternalVoiceCallFactory } from "./external-voice-gateway.js";
+
+interface VoiceSession {
+ controller: AbortController;
+ call?: ExternalVoiceCall;
+ creation?: Promise;
+ seen: Set;
+ initializationTimeout?: ReturnType;
+ stopping?: Promise;
+}
+
+function object(value: unknown): value is JsonObject {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+const voices = new Set([
+ "juniper",
+ "maple",
+ "spruce",
+ "ember",
+ "vale",
+ "breeze",
+ "arbor",
+ "sol",
+ "cove",
+]);
+
+/** External-only native call lifecycle. Native Codex requests never enter this owner. */
+export class ExternalVoice {
+ readonly #sessions = new Map();
+ constructor(
+ readonly options: {
+ createCall: ExternalVoiceCallFactory;
+ notify(method: string, params: JsonObject): Promise;
+ runTask(threadId: string, text: string, signal: AbortSignal): Promise;
+ diagnose(): void;
+ },
+ ) {}
+
+ has(threadId: string): boolean {
+ return this.#sessions.has(threadId);
+ }
+
+ async start(threadId: string, params: JsonObject): Promise {
+ if (this.has(threadId)) throw new Error("This chat already has a voice call");
+ if (
+ !object(params.transport) ||
+ params.transport.type !== "webrtc" ||
+ typeof params.transport.sdp !== "string" ||
+ !params.transport.sdp.startsWith("v=0") ||
+ params.transport.sdp.length > 256_000 ||
+ params.outputModality !== "audio"
+ ) {
+ throw new Error("External voice requires the native WebRTC audio call transport");
+ }
+ if (params.voice != null && (typeof params.voice !== "string" || !voices.has(params.voice))) {
+ throw new Error("Selected voice is unavailable for the external live-call protocol");
+ }
+ const session: VoiceSession = { controller: new AbortController(), seen: new Set() };
+ this.#sessions.set(threadId, session);
+ const requireActive = (): void => {
+ if (session.controller.signal.aborted || this.#sessions.get(threadId) !== session)
+ throw new Error("External voice start cancelled");
+ };
+ try {
+ session.creation = this.options.createCall({
+ sdp: params.transport.sdp,
+ ...(typeof params.voice === "string" ? { voice: params.voice } : {}),
+ signal: session.controller.signal,
+ onEvent: (event) => {
+ void this.#event(threadId, session, event).catch(() => this.#fail(threadId, session));
+ },
+ onError: () => {
+ void this.#fail(threadId, session);
+ },
+ });
+ session.call = await session.creation;
+ if (session.controller.signal.aborted) {
+ await session.call.close();
+ throw new Error("External voice start cancelled");
+ }
+ requireActive();
+ await this.options.notify("thread/realtime/started", {
+ threadId,
+ realtimeSessionId: randomUUID(),
+ version: "v3",
+ });
+ requireActive();
+ await this.options.notify("thread/realtime/sdp", { threadId, sdp: session.call.sdp });
+ requireActive();
+ session.initializationTimeout = setTimeout(() => {
+ void this.#fail(threadId, session);
+ }, 20_000);
+ session.initializationTimeout.unref();
+ void session.call.connect().catch(() => this.#fail(threadId, session));
+ } catch {
+ await this.#fail(threadId, session);
+ throw new Error("External voice could not start");
+ }
+ }
+
+ async stop(threadId: string, reason = "requested"): Promise {
+ const session = this.#sessions.get(threadId);
+ if (!session) return;
+ if (session.stopping) return session.stopping;
+ session.controller.abort();
+ clearTimeout(session.initializationTimeout);
+ session.stopping = (async () => {
+ let failed = false;
+ try {
+ const call = session.call ?? (await session.creation?.catch(() => undefined));
+ await call?.close();
+ } catch {
+ failed = true;
+ this.options.diagnose();
+ } finally {
+ if (this.#sessions.get(threadId) === session) this.#sessions.delete(threadId);
+ await this.options.notify("thread/realtime/closed", {
+ threadId,
+ reason: failed ? "cleanup_unconfirmed" : reason,
+ });
+ }
+ if (failed) throw new Error("External voice cleanup was not confirmed");
+ })();
+ return session.stopping;
+ }
+
+ async close(): Promise {
+ await Promise.allSettled([...this.#sessions.keys()].map((id) => this.stop(id)));
+ }
+
+ async #fail(threadId: string, session: VoiceSession): Promise {
+ if (session.controller.signal.aborted || this.#sessions.get(threadId) !== session) return;
+ try {
+ await this.options.notify("thread/realtime/error", {
+ threadId,
+ message: "External voice connection failed. Retry the call explicitly.",
+ });
+ } catch {
+ this.options.diagnose();
+ } finally {
+ await this.stop(threadId, "error").catch(() => undefined);
+ }
+ }
+
+ async #event(threadId: string, session: VoiceSession, event: JsonObject): Promise {
+ if (session.controller.signal.aborted || this.#sessions.get(threadId) !== session) return;
+ if (event.type === "error") {
+ await this.#fail(threadId, session);
+ return;
+ }
+ if (
+ (event.type === "session.started" || event.type === "session.updated") &&
+ object(event.session) &&
+ typeof event.session.id === "string"
+ ) {
+ clearTimeout(session.initializationTimeout);
+ return;
+ }
+ if (event.type === "input_transcript.added" || event.type === "output_transcript.added") {
+ if (object(event.item) && typeof event.item.text === "string") {
+ await this.options.notify("thread/realtime/transcript/delta", {
+ threadId,
+ role: event.type === "input_transcript.added" ? "user" : "assistant",
+ delta: event.item.text,
+ });
+ }
+ return;
+ }
+ if (event.type === "turn.done" && object(event.turn)) {
+ if (
+ ["user", "assistant"].includes(String(event.turn.role)) &&
+ typeof event.turn.transcript === "string"
+ ) {
+ await this.options.notify("thread/realtime/transcript/done", {
+ threadId,
+ role: String(event.turn.role),
+ text: event.turn.transcript,
+ });
+ }
+ return;
+ }
+ if (event.type !== "delegation.created" || !object(event.item)) return;
+ const item = event.item;
+ if (
+ item.type !== "delegation" ||
+ item.target !== "client" ||
+ typeof item.id !== "string" ||
+ !item.id ||
+ item.id.length > 256
+ )
+ return;
+ if (session.seen.has(item.id)) return;
+ if (session.seen.size >= 512) {
+ await this.#fail(threadId, session);
+ return;
+ }
+ session.seen.add(item.id);
+ const text = Array.isArray(item.content)
+ ? item.content
+ .filter(object)
+ .filter((part) => part.type === "input_text" && typeof part.text === "string")
+ .map((part) => part.text)
+ .join("")
+ .trim()
+ : "";
+ if (!text || text.length > 32_000)
+ throw new Error("External voice delegation has no valid task text");
+ let result: string;
+ try {
+ result = await this.options.runTask(threadId, text, session.controller.signal);
+ } catch {
+ result =
+ "The selected agent could not complete this request. No completion is confirmed. Check the chat for its current task status.";
+ }
+ if (session.controller.signal.aborted || this.#sessions.get(threadId) !== session) return;
+ // Conservative Unicode chunks stay below the protocol's context append limit.
+ const characters = [...result].slice(0, 8_000);
+ const call = session.call;
+ if (!call) return;
+ for (let offset = 0; offset < characters.length; offset += 120) {
+ call.send({
+ type: "delegation.context.append",
+ delegation_item_id: item.id,
+ channel: "speakable",
+ content: [{ type: "input_text", text: characters.slice(offset, offset + 120).join("") }],
+ });
+ }
+ }
+}
diff --git a/packages/host-runtime/src/index.ts b/packages/host-runtime/src/index.ts
index 471544b40..d30c82e90 100644
--- a/packages/host-runtime/src/index.ts
+++ b/packages/host-runtime/src/index.ts
@@ -38,6 +38,7 @@ export { runDelegationCli, DELEGATION_HELP } from "./delegation-cli.js";
export { DelegationControlRegistry } from "./delegation-control-registry.js";
export { startDelegationControlServer } from "./delegation-control-server.js";
export { installDelegationSkills, CODEXHOST_DELEGATION_SKILL } from "./delegation-skill.js";
+export { installCodexhostDesktopRules, CODEXHOST_DESKTOP_RULE } from "./codexhost-desktop-rule.js";
export {
DELEGATION_CLI_PATH_ENV,
DELEGATION_CLI_NODE_PATH_ENV,
diff --git a/packages/host-runtime/src/run-host-runtime.ts b/packages/host-runtime/src/run-host-runtime.ts
index 39ba74813..d8063171e 100644
--- a/packages/host-runtime/src/run-host-runtime.ts
+++ b/packages/host-runtime/src/run-host-runtime.ts
@@ -15,6 +15,7 @@ import { createOwnedUnixBackend } from "./codex-runtime/owned-official-backends.
import { DelegationControlRegistry } from "./delegation-control-registry.js";
import { installedHarnessPluginOptions } from "./installed-harness-plugins.js";
import { startDelegationControlServer } from "./delegation-control-server.js";
+import { installCodexhostDesktopRules } from "./codexhost-desktop-rule.js";
import { installDelegationSkills } from "./delegation-skill.js";
import type { DelegationControlRegistration } from "./delegation-types.js";
import { readRemoteDelegationThread } from "./remote-delegation-read.js";
@@ -140,6 +141,24 @@ export function delegationCliEnvironment(
};
}
+async function installManagedHomeFiles(
+ install: () => Promise>,
+ label: string,
+): Promise {
+ try {
+ const results = await install();
+ for (const result of results) {
+ if (result.status === "conflict") {
+ process.stderr.write(
+ `codexhost ${label} conflict: preserving user-managed file at ${result.path}\n`,
+ );
+ }
+ }
+ } catch (error) {
+ process.stderr.write(`codexhost ${label} installation failed: ${String(error)}\n`);
+ }
+}
+
async function prepareDelegationRuntime(input: {
environment: NodeJS.ProcessEnv;
hostRuntimePath?: string;
@@ -164,19 +183,8 @@ async function prepareDelegationRuntime(input: {
[DELEGATION_RUNTIME_ENDPOINT_ENV]: server.endpoint,
[DELEGATION_RUNTIME_TOKEN_ENV]: token,
};
- await installDelegationSkills()
- .then((results) => {
- for (const result of results) {
- if (result.status === "conflict") {
- process.stderr.write(
- `codexhost delegation Skill conflict: preserving user-managed file at ${result.path}\n`,
- );
- }
- }
- })
- .catch((error) => {
- process.stderr.write(`codexhost delegation Skill installation failed: ${String(error)}\n`);
- });
+ await installManagedHomeFiles(installDelegationSkills, "delegation Skill");
+ await installManagedHomeFiles(installCodexhostDesktopRules, "Desktop rule");
try {
return await input.createHost(environment, (value) => registry.register(value), registry);
} finally {
diff --git a/packages/host-runtime/test/app-server-host-fixture.ts b/packages/host-runtime/test/app-server-host-fixture.ts
index b10864870..b67e56424 100644
--- a/packages/host-runtime/test/app-server-host-fixture.ts
+++ b/packages/host-runtime/test/app-server-host-fixture.ts
@@ -289,6 +289,7 @@ export function createFixture(
onCreateRequestRoute?: AppServerHostOptions["onCreateRequestRoute"];
modelPrices?: AppServerHostOptions["modelPrices"];
usageStatistics?: AppServerHostOptions["usageStatistics"];
+ createExternalVoiceCall?: AppServerHostOptions["createExternalVoiceCall"];
} = {},
) {
const adapter =
@@ -358,6 +359,9 @@ export function createFixture(
...(options.consoleOpener ? { consoleOpener: options.consoleOpener } : {}),
...(options.modelPrices ? { modelPrices: options.modelPrices } : {}),
...(options.usageStatistics ? { usageStatistics: options.usageStatistics } : {}),
+ ...(options.createExternalVoiceCall
+ ? { createExternalVoiceCall: options.createExternalVoiceCall }
+ : {}),
...(options.accountControl ? { accountControl: options.accountControl } : {}),
...(options.officialRuntimeScope ? { officialRuntimeScope: options.officialRuntimeScope } : {}),
...(options.onDelegationApi ? { onDelegationApi: options.onDelegationApi } : {}),
diff --git a/packages/host-runtime/test/app-server-host.external-sandbox.test.ts b/packages/host-runtime/test/app-server-host.external-sandbox.test.ts
new file mode 100644
index 000000000..768f2db62
--- /dev/null
+++ b/packages/host-runtime/test/app-server-host.external-sandbox.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it, vi } from "vitest";
+import type { JsonObject } from "@codexhost/shared-contracts";
+import {
+ completePiTurn,
+ createFixture,
+ requestId,
+ startExternalThread,
+ stopFixture,
+ writeRequest,
+} from "./app-server-host-fixture.js";
+
+const externalSandbox = { type: "externalSandbox", networkAccess: "enabled" };
+
+describe("external Thread sandbox ownership", () => {
+ it.each(["read-only", "workspace-write", "danger-full-access"])(
+ "does not claim Codex enforces the requested %s sandbox",
+ async (sandbox) => {
+ const fixture = createFixture();
+ const open = vi.spyOn(fixture.adapter, "open");
+ try {
+ await startExternalThread(fixture, "codexhost/pi-native", 1, { sandbox });
+ expect(await fixture.collector.waitFor((message) => requestId(message, 1))).toMatchObject({
+ result: { sandbox: externalSandbox },
+ });
+ expect(open).toHaveBeenCalledOnce();
+ expect(open.mock.calls[0]?.[0]).not.toHaveProperty("sandbox");
+ expect(fixture.official.stdin.readableLength).toBe(0);
+ } finally {
+ await stopFixture(fixture);
+ }
+ },
+ );
+
+ it("keeps sandbox enforcement external after resume and fork", async () => {
+ const fixture = createFixture();
+ try {
+ const threadId = await startExternalThread(fixture, "codexhost/pi-native");
+ await completePiTurn(fixture, threadId, 2);
+ for (const [id, method] of [
+ [3, "thread/resume"],
+ [4, "thread/fork"],
+ ] as const) {
+ writeRequest(fixture.desktopInput, { id, method, params: { threadId } });
+ expect(await fixture.collector.waitFor((message) => requestId(message, id))).toMatchObject({
+ result: { sandbox: externalSandbox },
+ });
+ }
+ expect(fixture.official.stdin.readableLength).toBe(0);
+ } finally {
+ await stopFixture(fixture);
+ }
+ });
+
+ it("accepts the reasoning-summary patch used by native voice startup", async () => {
+ const fixture = createFixture();
+ try {
+ const threadId = await startExternalThread(fixture, "codexhost/pi-native");
+ const session = fixture.adapter.sessions[0];
+ if (!session) throw new Error("Expected external session");
+ const execute = vi.spyOn(session, "execute");
+ const before = session.state.effectivePermissionModeId;
+ writeRequest(fixture.desktopInput, {
+ id: 2,
+ method: "thread/settings/update",
+ params: { threadId, summary: "detailed" },
+ });
+ expect(await fixture.collector.waitFor((message) => requestId(message, 2))).toMatchObject({
+ result: {},
+ });
+ expect(execute).not.toHaveBeenCalled();
+ expect(session.state.effectivePermissionModeId).toBe(before);
+ expect(fixture.official.stdin.readableLength).toBe(0);
+ } finally {
+ await stopFixture(fixture);
+ }
+ });
+
+ it("acknowledges native permission patches without applying them", async () => {
+ const fixture = createFixture();
+ try {
+ const threadId = await startExternalThread(fixture, "codexhost/pi-native");
+ const session = fixture.adapter.sessions[0];
+ if (!session) throw new Error("Expected external session");
+ const execute = vi.spyOn(session, "execute");
+ const before = session.state.effectivePermissionModeId;
+ writeRequest(fixture.desktopInput, {
+ id: 2,
+ method: "thread/settings/update",
+ params: { threadId, sandboxPolicy: { type: "dangerFullAccess" } },
+ });
+ expect(await fixture.collector.waitFor((message) => requestId(message, 2))).toMatchObject({
+ result: {},
+ });
+ expect(execute).not.toHaveBeenCalled();
+ expect(session.state.effectivePermissionModeId).toBe(before);
+ expect(fixture.official.stdin.readableLength).toBe(0);
+ } finally {
+ await stopFixture(fixture);
+ }
+ });
+});
diff --git a/packages/host-runtime/test/app-server-host.external-voice.test.ts b/packages/host-runtime/test/app-server-host.external-voice.test.ts
new file mode 100644
index 000000000..db360bd31
--- /dev/null
+++ b/packages/host-runtime/test/app-server-host.external-voice.test.ts
@@ -0,0 +1,134 @@
+import { FakeHarnessAdapter } from "@codexhost/harness-adapter/testing";
+import { harnessIdSchema, type JsonObject } from "@codexhost/shared-contracts";
+import { describe, expect, it, vi } from "vitest";
+import type { ExternalVoiceCallFactory } from "../src/external-voice-gateway.js";
+import {
+ createFixture,
+ requestId,
+ startExternalThread,
+ stopFixture,
+ writeRequest,
+} from "./app-server-host-fixture.js";
+
+function transport() {
+ let input: Parameters[0];
+ const sent: JsonObject[] = [];
+ const close = vi.fn(async () => {});
+ const createCall: ExternalVoiceCallFactory = vi.fn(async (options) => {
+ input = options;
+ return {
+ sdp: "v=0\r\nanswer",
+ connect: async () => {},
+ send: (event: JsonObject) => {
+ sent.push(event);
+ },
+ close,
+ };
+ });
+ return {
+ createCall,
+ close,
+ sent,
+ event: (event: JsonObject) => input.onEvent(event),
+ fail: () => input.onError(),
+ };
+}
+
+describe("external native voice routing", () => {
+ it.each(["grok", "claude-code"])(
+ "uses the selected %s session and never starts a Codex task",
+ async (name) => {
+ const adapter = new FakeHarnessAdapter(harnessIdSchema.parse(name));
+ const call = transport();
+ const fixture = createFixture({
+ externalAdapters: new Map([[name, adapter]]),
+ createExternalVoiceCall: call.createCall,
+ });
+ try {
+ const threadId = await startExternalThread(fixture, `codexhost/${name}-native`);
+ writeRequest(fixture.desktopInput, {
+ id: 2,
+ method: "thread/realtime/start",
+ params: {
+ threadId,
+ outputModality: "audio",
+ transport: { type: "webrtc", sdp: "v=0\r\noffer" },
+ },
+ });
+ expect(await fixture.collector.waitFor((message) => requestId(message, 2))).toMatchObject({
+ result: {},
+ });
+ expect(fixture.collector.messages).toContainEqual(
+ expect.objectContaining({
+ method: "thread/realtime/sdp",
+ params: { threadId, sdp: "v=0\r\nanswer" },
+ }),
+ );
+ const delegation = {
+ type: "delegation.created",
+ item: {
+ type: "delegation",
+ target: "client",
+ id: "task-one",
+ content: [{ type: "input_text", text: "Three plus two" }],
+ },
+ };
+ call.event(delegation);
+ call.event(delegation);
+ const session = adapter.sessions[0];
+ if (!session) throw new Error("Expected selected Harness session");
+ await fixture.collector.waitFor(
+ (message) =>
+ message.method === "turn/started" &&
+ (message.params as JsonObject)?.threadId === threadId,
+ );
+ session.startReasoning("PRIVATE REASONING MUST NOT BE SPOKEN");
+ session.appendText("Five");
+ session.succeedTurn();
+ await vi.waitFor(() => expect(call.sent).toHaveLength(1));
+ expect(call.sent[0]).toEqual({
+ type: "delegation.context.append",
+ delegation_item_id: "task-one",
+ channel: "speakable",
+ content: [{ type: "input_text", text: "Five" }],
+ });
+ expect(adapter.sessions).toHaveLength(1);
+ expect(fixture.official.stdin.readableLength).toBe(0);
+ writeRequest(fixture.desktopInput, {
+ id: 3,
+ method: "thread/realtime/stop",
+ params: { threadId },
+ });
+ await fixture.collector.waitFor((message) => requestId(message, 3));
+ expect(call.close).toHaveBeenCalledTimes(1);
+ } finally {
+ await stopFixture(fixture);
+ }
+ },
+ );
+
+ it("closes a failed call and permits an explicit retry", async () => {
+ const call = transport();
+ const fixture = createFixture({ createExternalVoiceCall: call.createCall });
+ try {
+ const threadId = await startExternalThread(fixture, "codexhost/pi-native");
+ const params = {
+ threadId,
+ outputModality: "audio",
+ transport: { type: "webrtc", sdp: "v=0\r\noffer" },
+ };
+ writeRequest(fixture.desktopInput, { id: 2, method: "thread/realtime/start", params });
+ await fixture.collector.waitFor((message) => requestId(message, 2));
+ call.fail();
+ await fixture.collector.waitFor((message) => message.method === "thread/realtime/closed");
+ expect(call.close).toHaveBeenCalledTimes(1);
+ writeRequest(fixture.desktopInput, { id: 3, method: "thread/realtime/start", params });
+ expect(await fixture.collector.waitFor((message) => requestId(message, 3))).toMatchObject({
+ result: {},
+ });
+ expect(call.createCall).toHaveBeenCalledTimes(2);
+ } finally {
+ await stopFixture(fixture);
+ }
+ });
+});
diff --git a/packages/host-runtime/test/app-server-host.official-passthrough.test.ts b/packages/host-runtime/test/app-server-host.official-passthrough.test.ts
index beaf01696..e860ed1c2 100644
--- a/packages/host-runtime/test/app-server-host.official-passthrough.test.ts
+++ b/packages/host-runtime/test/app-server-host.official-passthrough.test.ts
@@ -63,7 +63,24 @@ describe("official Codex passthrough", () => {
params: { threadId: "official-thread", gitInfo: { branch: { name: "future" } } },
},
{ method: "thread/section/move", params: { threadId: "official-thread", sectionId: 7 } },
+ {
+ method: "thread/settings/update",
+ params: { threadId: "official-thread", summary: "detailed" },
+ },
{ method: "turn/start" },
+ {
+ method: "thread/realtime/start",
+ params: {
+ threadId: "official-voice",
+ outputModality: "audio",
+ transport: { type: "webrtc", sdp: "native-offer" },
+ },
+ },
+ { method: "thread/realtime/stop", params: { threadId: "official-voice" } },
+ {
+ method: "thread/realtime/appendText",
+ params: { threadId: "official-voice", role: "user", text: "native text" },
+ },
{ method: "turn/interrupt", params: "future-params-shape" },
{ method: "thread/read" },
{ method: "future/method", params: { anything: [1, "two"] } },
diff --git a/packages/host-runtime/test/codexhost-desktop-rule.test.ts b/packages/host-runtime/test/codexhost-desktop-rule.test.ts
new file mode 100644
index 000000000..5a1256686
--- /dev/null
+++ b/packages/host-runtime/test/codexhost-desktop-rule.test.ts
@@ -0,0 +1,117 @@
+import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ CODEXHOST_DESKTOP_RULE,
+ installCodexhostDesktopRules,
+} from "../src/codexhost-desktop-rule.js";
+
+async function home(): Promise {
+ return mkdtemp(path.join(os.tmpdir(), "codexhost-desktop-rule-"));
+}
+
+function grokPath(root: string): string {
+ return path.join(root, ".grok", "rules", "codexhost-desktop.md");
+}
+
+function claudePath(root: string): string {
+ return path.join(root, ".claude", "rules", "codexhost-desktop.md");
+}
+
+describe("Codex Desktop rule installation", () => {
+ it("installs only the Grok home rule", async () => {
+ const root = await home();
+ const results = await installCodexhostDesktopRules({ homeDirectory: root });
+ expect(results.map((result) => result.status)).toEqual(["installed"]);
+ await expect(readFile(grokPath(root), "utf8")).resolves.toBe(CODEXHOST_DESKTOP_RULE);
+ await expect(stat(claudePath(root))).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("does not rewrite a Grok copy already at the current version", async () => {
+ const root = await home();
+ await installCodexhostDesktopRules({ homeDirectory: root });
+ const file = grokPath(root);
+ const before = await stat(file);
+ const results = await installCodexhostDesktopRules({ homeDirectory: root });
+ const after = await stat(file);
+ expect(results.map((result) => result.status)).toEqual(["current"]);
+ expect(after.mtimeMs).toBe(before.mtimeMs);
+ });
+
+ it("updates a Grok copy whose digest matches a previous managed version", async () => {
+ const root = await home();
+ const previous = "\nold\n";
+ const destination = grokPath(root);
+ await mkdir(path.dirname(destination), { recursive: true });
+ await writeFile(destination, previous, "utf8");
+ const { createHash } = await import("node:crypto");
+ const results = await installCodexhostDesktopRules({
+ homeDirectory: root,
+ previousManagedDigests: [createHash("sha256").update(previous).digest("hex")],
+ });
+ expect(results.map((result) => result.status)).toEqual(["updated"]);
+ await expect(readFile(destination, "utf8")).resolves.toBe(CODEXHOST_DESKTOP_RULE);
+ });
+
+ it("preserves a user-modified Grok copy", async () => {
+ const root = await home();
+ const grok = grokPath(root);
+ await mkdir(path.dirname(grok), { recursive: true });
+ await writeFile(grok, "user content\n", "utf8");
+ const results = await installCodexhostDesktopRules({ homeDirectory: root });
+ expect(results.map((result) => result.status)).toEqual(["conflict"]);
+ await expect(readFile(grok, "utf8")).resolves.toBe("user content\n");
+ });
+
+ it("removes a previously managed Claude copy and preserves a user-edited Claude copy", async () => {
+ const root = await home();
+ const claude = claudePath(root);
+ await mkdir(path.dirname(claude), { recursive: true });
+ const previous = "\nold\n";
+ await writeFile(claude, previous, "utf8");
+ const { createHash } = await import("node:crypto");
+ const removed = await installCodexhostDesktopRules({
+ homeDirectory: root,
+ previousManagedDigests: [createHash("sha256").update(previous).digest("hex")],
+ });
+ expect(removed.map((result) => result.status)).toEqual(["installed", "removed"]);
+ await expect(stat(claude)).rejects.toMatchObject({ code: "ENOENT" });
+
+ const other = await home();
+ const otherClaude = claudePath(other);
+ await mkdir(path.dirname(otherClaude), { recursive: true });
+ await writeFile(otherClaude, "user claude rule\n", "utf8");
+ const conflict = await installCodexhostDesktopRules({ homeDirectory: other });
+ expect(conflict.map((result) => result.status)).toEqual(["installed", "conflict"]);
+ await expect(readFile(otherClaude, "utf8")).resolves.toBe("user claude rule\n");
+ });
+
+ it("fail-closes on CODEXHOST_THREAD_ID without printing secrets", () => {
+ expect(CODEXHOST_DESKTOP_RULE).toContain("codexhost-desktop-rule: 3");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("CODEXHOST_THREAD_ID");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("realtime-voice-chat");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("leave the session rules inactive");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("even when cwd is a real repository");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("Never print");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("CODEXHOST_RUNTIME_TOKEN");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("echo set");
+ expect(CODEXHOST_DESKTOP_RULE).not.toContain("echo $CODEXHOST_THREAD_ID");
+ expect(CODEXHOST_DESKTOP_RULE).not.toContain("OpenPets");
+ });
+
+ it("captures the Desktop session rules with Harness vs Model wording", () => {
+ expect(CODEXHOST_DESKTOP_RULE).toContain("this Harness's Model");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("flash-max");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("Gemini Flash");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("grok-4.6");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("Playground");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("gpt-live");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("SkillWiki on Grok");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("Markdown View");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("thread/realtime/*");
+ expect(CODEXHOST_DESKTOP_RULE).toContain("AGENT_TOOLS_AND_RULES.md");
+ });
+});
diff --git a/packages/host-runtime/test/external-voice-gateway.test.ts b/packages/host-runtime/test/external-voice-gateway.test.ts
new file mode 100644
index 000000000..e5f965085
--- /dev/null
+++ b/packages/host-runtime/test/external-voice-gateway.test.ts
@@ -0,0 +1,200 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import type WebSocket from "ws";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { configuredExternalVoiceGateway } from "../src/external-voice-gateway.js";
+
+const connections = vi.hoisted(() => [] as { url: string; options: WebSocket.ClientOptions }[]);
+
+vi.mock("ws", async () => {
+ const { EventEmitter } = await import("node:events");
+ return {
+ default: class extends EventEmitter {
+ static OPEN = 1;
+ readyState = 1;
+ send = vi.fn();
+ terminate = vi.fn();
+ constructor(url: URL, options: WebSocket.ClientOptions) {
+ super();
+ connections.push({ url: String(url), options });
+ queueMicrotask(() => this.emit("open"));
+ }
+ },
+ };
+});
+
+const homes: string[] = [];
+const input = () => ({
+ sdp: "v=0\r\nisolated-offer",
+ signal: new AbortController().signal,
+ onEvent: vi.fn(),
+ onError: vi.fn(),
+});
+
+async function gateway(extra = "", provider = 'env_key = "TEST_VOICE_KEY"') {
+ const home = await mkdtemp(path.join(tmpdir(), "host-voice-gateway-"));
+ homes.push(home);
+ await writeFile(
+ path.join(home, "config.toml"),
+ `model_provider = "selected"
+experimental_realtime_webrtc_call_base_url = "https://gateway.example/v1"
+${extra}
+[model_providers.selected]
+${provider}
+[model_providers.unselected]
+experimental_bearer_token = "wrong-provider-fixture"
+`,
+ );
+ return configuredExternalVoiceGateway({ CODEX_HOME: home, TEST_VOICE_KEY: "env-fixture" });
+}
+
+function created() {
+ return new Response("v=0\r\nisolated-answer", {
+ status: 201,
+ headers: { Location: "/v1/realtime/calls/test-call" },
+ });
+}
+
+afterEach(async () => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ connections.length = 0;
+ await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true })));
+});
+
+describe("configured voice gateway", () => {
+ it.each(["http://[invalid", "/unrecognized", ""])(
+ "rejects an unusable call identity without claiming remote cleanup: %s",
+ async (location) => {
+ const response = new Response("answer", {
+ status: 201,
+ headers: { Location: location },
+ });
+ const request = vi.fn().mockResolvedValue(response);
+ vi.stubGlobal("fetch", request);
+ const factory = await gateway();
+ await expect(factory(input())).rejects.toThrow("remote cleanup is unconfirmed");
+ expect(response.body?.locked).toBe(false);
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(connections).toHaveLength(0);
+ },
+ );
+
+ it.each([
+ ['env_key = "TEST_VOICE_KEY"', "env-fixture"],
+ ['experimental_bearer_token = "bearer-fixture"', "bearer-fixture"],
+ ])(
+ "uses the selected provider credential for native call and control: %s",
+ async (provider, token) => {
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce(created())
+ .mockResolvedValueOnce(new Response());
+ vi.stubGlobal("fetch", request);
+ const factory = await gateway("", provider);
+ const callbacks = input();
+ const call = await factory(callbacks);
+ expect(call.sdp).toBe("v=0\r\nisolated-answer");
+ const creation = request.mock.calls[0];
+ if (!creation) throw new Error("Call creation request is missing");
+ const [url, init] = creation;
+ expect(url).toBe("https://gateway.example/v1/realtime/calls");
+ expect(init).toMatchObject({
+ method: "POST",
+ redirect: "error",
+ headers: { Authorization: `Bearer ${token}`, "OpenAI-Alpha": "quicksilver=v2" },
+ });
+ const form = init?.body as FormData;
+ expect(form.get("sdp")).toBe(callbacks.sdp);
+ expect(JSON.parse(String(form.get("session")))).toMatchObject({
+ model: "gpt-realtime",
+ delegation: { type: "client", ack_filler: false },
+ audio: { output: { voice: "cove" } },
+ });
+ await call.connect();
+ expect(connections).toHaveLength(1);
+ expect(connections[0]).toMatchObject({
+ url: "wss://gateway.example/v1/live/test-call",
+ options: { headers: init?.headers, autoSelectFamily: true },
+ });
+ await Promise.all([call.close(), call.close()]);
+ expect(request).toHaveBeenCalledTimes(2);
+ expect(request.mock.calls[1]?.[0]).toBe(
+ "https://gateway.example/v1/realtime/calls/test-call/hangup",
+ );
+ await expect(call.connect()).rejects.toThrow("closed");
+ expect(callbacks.onError).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([
+ 'experimental_realtime_ws_base_url = "http://gateway.example/v1"',
+ 'experimental_realtime_ws_base_url = "https://other.example/v1"',
+ 'experimental_realtime_ws_base_url = "https://user:password@gateway.example/v1"',
+ 'experimental_realtime_ws_base_url = "https://gateway.example/v1?key=fixture"',
+ 'experimental_realtime_ws_base_url = "https://gateway.example/v1#fragment"',
+ 'experimental_realtime_ws_base_url = "malformed-url"',
+ ])("rejects unsafe gateway configuration before sending credentials: %s", async (extra) => {
+ const request = vi.fn();
+ vi.stubGlobal("fetch", request);
+ const factory = await gateway(extra);
+ await expect(factory(input())).rejects.toThrow(/External voice/);
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it("never substitutes another provider or OAuth when the selected credential is missing", async () => {
+ const request = vi.fn();
+ vi.stubGlobal("fetch", request);
+ const factory = await gateway("", 'env_key = "MISSING_KEY"');
+ await expect(factory(input())).rejects.toThrow(
+ "configured realtime gateway and API credential",
+ );
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it("does not expose a rejected gateway response body", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(new Response("private-fixture-error", { status: 401 })),
+ );
+ const factory = await gateway();
+ await expect(factory(input())).rejects.toThrow(/^External voice call creation failed$/);
+ });
+
+ it("hangs up a call cancelled while its SDP answer is still loading", async () => {
+ const body = Promise.withResolvers();
+ const response = created();
+ vi.spyOn(response, "text").mockImplementation(() => body.promise);
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce(response)
+ .mockResolvedValueOnce(new Response(null, { status: 404 }));
+ vi.stubGlobal("fetch", request);
+ const controller = new AbortController();
+ const factory = await gateway();
+ const starting = factory({ ...input(), signal: controller.signal });
+ const rejected = expect(starting).rejects.toThrow("startup failed");
+ await vi.waitFor(() => expect(response.text).toHaveBeenCalled());
+ controller.abort();
+ body.resolve("v=0\r\nlate-answer");
+ await rejected;
+ expect(request).toHaveBeenCalledTimes(2);
+ expect(request.mock.calls[1]?.[0]).toContain("/test-call/hangup");
+ expect(request.mock.calls[1]?.[1]?.signal?.aborted).toBe(false);
+ expect(connections).toHaveLength(0);
+ });
+
+ it("cleans up a call whose SDP body fails and reports unconfirmed hangup", async () => {
+ const response = created();
+ vi.spyOn(response, "text").mockRejectedValue(new Error("broken stream"));
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce(response)
+ .mockResolvedValueOnce(new Response(null, { status: 503 }));
+ vi.stubGlobal("fetch", request);
+ const factory = await gateway();
+ await expect(factory(input())).rejects.toThrow("hangup was not confirmed");
+ expect(request).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/host-runtime/test/external-voice-lifecycle.test.ts b/packages/host-runtime/test/external-voice-lifecycle.test.ts
new file mode 100644
index 000000000..04af1a698
--- /dev/null
+++ b/packages/host-runtime/test/external-voice-lifecycle.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it, vi } from "vitest";
+import { ExternalVoice } from "../src/external-voice.js";
+import type { ExternalVoiceCall } from "../src/external-voice-gateway.js";
+
+const params = { outputModality: "audio", transport: { type: "webrtc", sdp: "v=0\r\noffer" } };
+
+describe("voice lifecycle", () => {
+ it.each(["thread/realtime/started", "thread/realtime/sdp"])(
+ "does not resume startup after hangup while publishing %s",
+ async (blockedMethod) => {
+ const published = Promise.withResolvers();
+ const release = Promise.withResolvers();
+ const connect = vi.fn(async () => {});
+ const close = vi.fn(async () => {});
+ const notify = vi.fn(async (method) => {
+ if (method === blockedMethod) {
+ published.resolve(undefined);
+ await release.promise;
+ }
+ });
+ const voice = new ExternalVoice({
+ createCall: async () => ({ sdp: "answer", connect, close, send: () => {} }),
+ notify,
+ runTask: vi.fn(),
+ diagnose: vi.fn(),
+ });
+ const starting = voice.start("external", params);
+ const rejectedStart = expect(starting).rejects.toThrow("could not start");
+ await published.promise;
+ await voice.stop("external");
+ const notificationsAtHangup = notify.mock.calls.length;
+ release.resolve(undefined);
+ await rejectedStart;
+ expect(connect).not.toHaveBeenCalled();
+ expect(close).toHaveBeenCalledTimes(1);
+ expect(notify).toHaveBeenCalledTimes(notificationsAtHangup);
+ expect(notify.mock.calls.at(-1)?.[0]).toBe("thread/realtime/closed");
+ expect(voice.has("external")).toBe(false);
+ },
+ );
+
+ it("waits for cleanup when stopped during call creation and never publishes a late start", async () => {
+ const creation = Promise.withResolvers();
+ const close = vi.fn(async () => {});
+ const notify = vi.fn(async () => {});
+ const voice = new ExternalVoice({
+ createCall: () => creation.promise,
+ notify,
+ runTask: vi.fn(),
+ diagnose: vi.fn(),
+ });
+ const starting = voice.start("external", params);
+ const rejectedStart = expect(starting).rejects.toThrow("could not start");
+ const stopping = voice.stop("external");
+ expect(voice.has("external")).toBe(true);
+ creation.resolve({ sdp: "answer", close, connect: async () => {}, send: () => {} });
+ await stopping;
+ await rejectedStart;
+ expect(close).toHaveBeenCalled();
+ expect(notify.mock.calls.map((call) => call[0])).toEqual(["thread/realtime/closed"]);
+ expect(voice.has("external")).toBe(false);
+ });
+
+ it("suppresses a late agent answer after hangup without cancelling its work", async () => {
+ const answer = Promise.withResolvers();
+ let event: Parameters<
+ ConstructorParameters[0]["createCall"]
+ >[0]["onEvent"] = () => {};
+ const send = vi.fn();
+ const close = vi.fn(async () => {});
+ const task = vi.fn(() => answer.promise);
+ const voice = new ExternalVoice({
+ createCall: async (input) => {
+ event = input.onEvent;
+ return { sdp: "answer", connect: async () => {}, send, close };
+ },
+ runTask: task,
+ notify: async () => {},
+ diagnose: vi.fn(),
+ });
+ await voice.start("external", params);
+ event({
+ type: "delegation.created",
+ item: {
+ id: "task",
+ type: "delegation",
+ target: "client",
+ content: [{ type: "input_text", text: "request" }],
+ },
+ });
+ expect(task).toHaveBeenCalledTimes(1);
+ await voice.stop("external");
+ answer.resolve("old result");
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(send).not.toHaveBeenCalled();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/renderer-extension/src/cpa-live-voice-entry.ts b/packages/renderer-extension/src/cpa-live-voice-entry.ts
index 22b0b134b..27d7b52c9 100644
--- a/packages/renderer-extension/src/cpa-live-voice-entry.ts
+++ b/packages/renderer-extension/src/cpa-live-voice-entry.ts
@@ -1,4 +1,6 @@
import { installCpaLiveVoiceHook } from "./renderer-cpa-live-voice-hook.js";
+import { installOverlayThreadIsolation } from "./renderer-overlay-thread-isolation.js";
// Document-start: do not wait for body. Voice constructs clientOwnsCall during start.
installCpaLiveVoiceHook();
+installOverlayThreadIsolation();
diff --git a/packages/renderer-extension/src/production-entry.ts b/packages/renderer-extension/src/production-entry.ts
index 093af42cc..42c2fac9e 100644
--- a/packages/renderer-extension/src/production-entry.ts
+++ b/packages/renderer-extension/src/production-entry.ts
@@ -1,7 +1,9 @@
import { installCpaLiveVoiceHook } from "./renderer-cpa-live-voice-hook.js";
import { installRendererBinding } from "./install-renderer-binding.js";
+import { installExternalVoiceLaunchHook } from "./renderer-external-voice-launch.js";
installCpaLiveVoiceHook();
+installExternalVoiceLaunchHook();
const installBinding = (): void => {
installRendererBinding();
diff --git a/packages/renderer-extension/src/renderer-external-voice-launch.ts b/packages/renderer-extension/src/renderer-external-voice-launch.ts
new file mode 100644
index 000000000..bf866387e
--- /dev/null
+++ b/packages/renderer-extension/src/renderer-external-voice-launch.ts
@@ -0,0 +1,164 @@
+import type { RendererHostRouting } from "@codexhost/desktop-control/renderer-bindings";
+import {
+ AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
+ AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION,
+ auditedAppInitialAssetPresent,
+ readCpaLiveVoiceHookConfig,
+} from "./renderer-cpa-live-voice-hook.js";
+
+const HOOK_GLOBAL = "__codexhostExternalVoiceLaunchV1";
+const SHARED_ASSET = "app-shared-8bd9d0399038.js";
+type RecordValue = Record;
+
+function isRecord(value: unknown): value is RecordValue {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** The native overlay creates its Thread from collaborationMode.settings.model.
+ * Capture the primary Composer's explicit selection before crossing that boundary.
+ */
+export function routeExternalVoiceLaunch(request: unknown, routing?: RendererHostRouting): unknown {
+ if (!routing || !isRecord(request) || request.source !== "composer_button_new_thread")
+ return request;
+ const hostId = request.hostId ?? "local";
+ if (typeof hostId !== "string" || !hostId) return request;
+ const route = routing.forComposer();
+ if (!route || route.hostId !== hostId || routing.forHost(hostId) !== route) return request;
+ const model = route.policy.selectedModel();
+ if (!model?.startsWith("codexhost/")) return request;
+ const mode = request.activeCollaborationMode;
+ if (mode !== undefined && mode !== null && !isRecord(mode)) return request;
+ const settings = isRecord(mode) ? mode.settings : undefined;
+ if (settings !== undefined && settings !== null && !isRecord(settings)) return request;
+ return {
+ ...request,
+ activeCollaborationMode: {
+ ...(isRecord(mode) ? mode : { mode: "default" }),
+ settings: {
+ reasoning_effort: null,
+ developer_instructions: null,
+ ...(isRecord(settings) ? settings : {}),
+ model,
+ },
+ },
+ };
+}
+
+interface LaunchHook {
+ status: "pending" | "active" | "unsupported" | "disabled";
+ dispose(): void;
+}
+
+interface LaunchTarget {
+ [key: string]: unknown;
+ document?: ParentNode;
+ performance?: Pick;
+ setTimeout: typeof globalThis.setTimeout;
+ clearTimeout: typeof globalThis.clearTimeout;
+ __codexhostHostRoutingV1?: RendererHostRouting;
+}
+
+interface InstallOptions {
+ target?: LaunchTarget;
+ importModule?: (url: string) => Promise;
+}
+
+/** Audited browser module export fN is the native services record. Individual
+ * service RPC stubs reject mutation; replace only the voice service reference
+ * with a forwarding Proxy. No Node/Electron APIs or RPC wire interception.
+ */
+export function installExternalVoiceLaunchHook(options: InstallOptions = {}): LaunchHook {
+ const target = options.target ?? (globalThis as unknown as LaunchTarget);
+ const existing = target[HOOK_GLOBAL] as LaunchHook | undefined;
+ if (existing) return existing;
+ const config = readCpaLiveVoiceHookConfig(target);
+ if (!config) return { status: "disabled", dispose() {} };
+ if (
+ config.auditedDesktopVersion !== AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION ||
+ config.auditedAppInitialAsset !== AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET ||
+ (config.desktopVersion !== undefined && config.desktopVersion !== config.auditedDesktopVersion)
+ )
+ return { status: "unsupported", dispose() {} };
+ const importModule: (url: string) => Promise =
+ options.importModule ?? ((url: string) => import(url));
+ let disposed = false;
+ let timer: ReturnType | undefined;
+ let restore: (() => void) | undefined;
+ let module: RecordValue | undefined;
+ const started = Date.now();
+ const handle: LaunchHook = {
+ status: "pending",
+ dispose() {
+ disposed = true;
+ if (timer !== undefined) target.clearTimeout(timer);
+ restore?.();
+ if (target[HOOK_GLOBAL] === handle) Reflect.deleteProperty(target, HOOK_GLOBAL);
+ },
+ };
+ target[HOOK_GLOBAL] = handle;
+ const attempt = async (): Promise => {
+ if (disposed) return;
+ try {
+ const names =
+ target.performance?.getEntriesByType("resource").map((entry) => entry.name) ?? [];
+ for (const element of target.document?.querySelectorAll("script[src], link[href]") ?? []) {
+ const name = element.getAttribute("src") ?? element.getAttribute("href");
+ if (name) names.push(name);
+ }
+ if (
+ auditedAppInitialAssetPresent(names, AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET) &&
+ auditedAppInitialAssetPresent(names, SHARED_ASSET)
+ ) {
+ module ??= await importModule(`app://-/assets/${SHARED_ASSET}`);
+ if (disposed) return;
+ const services = module?.fN;
+ if (isRecord(services)) {
+ const original = services.realtimeVoiceRuntime;
+ if (typeof original === "function" || isRecord(original)) {
+ const start: unknown = Reflect.get(original, "requestRealtimeStart");
+ if (typeof start !== "function") {
+ handle.status = "unsupported";
+ return;
+ }
+ const launch = (...args: unknown[]): unknown => {
+ let request = args[0];
+ try {
+ request = routeExternalVoiceLaunch(request, target.__codexhostHostRoutingV1);
+ } catch {
+ // Retired or disconnected owners cannot claim native traffic.
+ }
+ return Reflect.apply(start, original, [request, ...args.slice(1)]);
+ };
+ const wrapped = new Proxy(original, {
+ get(service, key, receiver) {
+ return key === "requestRealtimeStart"
+ ? launch
+ : Reflect.get(service, key, receiver);
+ },
+ });
+ if (!Reflect.set(services, "realtimeVoiceRuntime", wrapped)) {
+ handle.status = "unsupported";
+ return;
+ }
+ restore = () => {
+ if (services.realtimeVoiceRuntime === wrapped)
+ services.realtimeVoiceRuntime = original;
+ };
+ handle.status = "active";
+ return;
+ }
+ }
+ }
+ } catch {
+ handle.status = "unsupported";
+ return;
+ }
+ if (Date.now() - started >= 60_000) {
+ handle.status = "unsupported";
+ return;
+ }
+ timer = target.setTimeout(() => void attempt(), 250);
+ };
+ void attempt();
+ return handle;
+}
diff --git a/packages/renderer-extension/src/renderer-overlay-thread-isolation.ts b/packages/renderer-extension/src/renderer-overlay-thread-isolation.ts
new file mode 100644
index 000000000..0c6dc337e
--- /dev/null
+++ b/packages/renderer-extension/src/renderer-overlay-thread-isolation.ts
@@ -0,0 +1,113 @@
+/** Overlay-only: leftover avatar-overlay must not ingest other Threads' live work. */
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+const BLOCKED_METHODS = new Set(["thread/started"]);
+
+export function isAvatarOverlayHref(href: string): boolean {
+ try {
+ const url = new URL(href);
+ if (url.pathname === "/avatar-overlay.html") return true;
+ return url.searchParams.get("initialRoute") === "/avatar-overlay";
+ } catch {
+ return false;
+ }
+}
+
+export function overlayBlocksHostNotification(
+ method: string,
+ threadId: string | undefined,
+ activeRealtimeThreadIds: ReadonlySet,
+): boolean {
+ if (method.startsWith("thread/realtime/")) {
+ return false;
+ }
+ if (threadId !== undefined && activeRealtimeThreadIds.has(threadId)) {
+ return false;
+ }
+ if (BLOCKED_METHODS.has(method)) return true;
+ return method.startsWith("turn/") || method.startsWith("item/");
+}
+
+export function notificationThreadId(params: unknown): string | undefined {
+ if (!isRecord(params)) return undefined;
+ if (typeof params.threadId === "string" && params.threadId.length > 0) return params.threadId;
+ const thread = params.thread;
+ if (isRecord(thread) && typeof thread.id === "string" && thread.id.length > 0) {
+ return thread.id;
+ }
+ return undefined;
+}
+
+export interface OverlayIsolationTarget {
+ location?: { href: string };
+ addEventListener(
+ type: "message",
+ listener: (event: OverlayIsolationMessageEvent) => void,
+ options?: boolean | { capture?: boolean },
+ ): void;
+ removeEventListener(
+ type: "message",
+ listener: (event: OverlayIsolationMessageEvent) => void,
+ options?: boolean | { capture?: boolean },
+ ): void;
+}
+
+export interface OverlayIsolationMessageEvent {
+ data?: unknown;
+ stopImmediatePropagation(): void;
+}
+
+export interface OverlayIsolationHandle {
+ status: "disabled" | "active";
+ dispose(): void;
+}
+
+/**
+ * Avatar overlay is a persistent Desktop webcontents. Host notifications are
+ * posted to every window. Without this filter, a leftover overlay upserts
+ * CLI-created Threads and Desktop rebinds the same Turn twice.
+ */
+export function installOverlayThreadIsolation(
+ target: OverlayIsolationTarget | null = typeof globalThis === "undefined"
+ ? null
+ : (globalThis as unknown as OverlayIsolationTarget),
+): OverlayIsolationHandle {
+ if (!target || typeof target.addEventListener !== "function") {
+ return { status: "disabled", dispose() {} };
+ }
+ const href = target.location?.href;
+ if (typeof href !== "string" || !isAvatarOverlayHref(href)) {
+ return { status: "disabled", dispose() {} };
+ }
+ const activeRealtimeThreadIds = new Set();
+ const listener = (event: OverlayIsolationMessageEvent): void => {
+ const message = event.data;
+ if (!isRecord(message) || message.type !== "mcp-notification") return;
+ if (typeof message.method !== "string") return;
+ const threadId = notificationThreadId(message.params);
+ if (message.method === "thread/realtime/started" && threadId) {
+ activeRealtimeThreadIds.add(threadId);
+ return;
+ }
+ if (
+ (message.method === "thread/realtime/closed" || message.method === "thread/realtime/error") &&
+ threadId
+ ) {
+ activeRealtimeThreadIds.delete(threadId);
+ return;
+ }
+ if (overlayBlocksHostNotification(message.method, threadId, activeRealtimeThreadIds)) {
+ event.stopImmediatePropagation();
+ }
+ };
+ target.addEventListener("message", listener, true);
+ return {
+ status: "active",
+ dispose() {
+ target.removeEventListener("message", listener, true);
+ },
+ };
+}
diff --git a/packages/renderer-extension/test/renderer-external-voice-launch.test.ts b/packages/renderer-extension/test/renderer-external-voice-launch.test.ts
new file mode 100644
index 000000000..d8c33e9d5
--- /dev/null
+++ b/packages/renderer-extension/test/renderer-external-voice-launch.test.ts
@@ -0,0 +1,236 @@
+import type { RendererHostRouting } from "@codexhost/desktop-control/renderer-bindings";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
+ AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION,
+ CPA_LIVE_VOICE_HOOK_GLOBAL,
+} from "../src/renderer-cpa-live-voice-hook.js";
+import {
+ installExternalVoiceLaunchHook,
+ routeExternalVoiceLaunch,
+} from "../src/renderer-external-voice-launch.js";
+
+const carrier = "codexhost/plugin-v1@grok-test-model-permission";
+const source = "composer_button_new_thread";
+const request = () => ({
+ source,
+ activeCollaborationMode: {
+ mode: "default",
+ settings: { model: "gpt-6.1-sol", reasoning_effort: "high", developer_instructions: "keep" },
+ },
+ projectId: "project-1",
+ pageCompanion: null,
+});
+
+function routingFixture() {
+ const policy = { selectedModel: vi.fn<() => string | null>(() => carrier) };
+ const route = { hostId: "local", policy };
+ const routing = {
+ forComposer: vi.fn(() => route),
+ forHost: vi.fn((hostId: string) => (hostId === route.hostId ? route : null)),
+ } as unknown as RendererHostRouting;
+ return { policy, route, routing };
+}
+
+function hookFixture() {
+ const { routing, policy } = routingFixture();
+ const start = vi.fn(function (this: unknown, ...args: unknown[]) {
+ return { receiver: this, args };
+ });
+ const other = vi.fn();
+ // Native services are a plain decoded record containing immutable RPC stubs.
+ const runtime = new Proxy(
+ { requestRealtimeStart: start, registerRealtimeStarter: other },
+ {
+ set() {
+ throw new Error("Can't assign properties on RPC stubs.");
+ },
+ defineProperty() {
+ throw new Error("Can't define properties on RPC stubs.");
+ },
+ },
+ );
+ const services = { realtimeVoiceRuntime: runtime, untouched: {} };
+ const names = [AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET, "app-shared-8bd9d0399038.js"];
+ const target = {
+ [CPA_LIVE_VOICE_HOOK_GLOBAL]: {
+ enabled: true,
+ auditedDesktopVersion: AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION,
+ auditedAppInitialAsset: AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
+ },
+ __codexhostHostRoutingV1: routing,
+ document: {
+ querySelectorAll: () => names.map((name) => ({ getAttribute: () => `/assets/${name}` })),
+ } as unknown as ParentNode,
+ setTimeout,
+ clearTimeout,
+ };
+ const importModule = vi.fn(async () => ({ fN: services }));
+ return { policy, services, runtime, start, other, names, target, importModule };
+}
+
+const settle = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+};
+afterEach(() => vi.useRealTimers());
+
+describe("native new-voice selection", () => {
+ it("snapshots the external route into a separately cloned overlay request", () => {
+ const { routing, policy } = routingFixture();
+ const original = request();
+ const routed = structuredClone(routeExternalVoiceLaunch(original, routing)) as ReturnType<
+ typeof request
+ >;
+ // Overlay's native start builder consumes precisely this settings.model.
+ const threadStart = { model: routed.activeCollaborationMode.settings.model };
+ expect(threadStart.model).toBe(carrier);
+ expect(routed.activeCollaborationMode.settings.developer_instructions).toBe("keep");
+ expect(routed.projectId).toBe(original.projectId);
+ expect(original.activeCollaborationMode.settings.model).toBe("gpt-6.1-sol");
+ policy.selectedModel.mockReturnValue(null);
+ expect(threadStart.model).toBe(carrier);
+ expect(routeExternalVoiceLaunch(original, routing)).toBe(original);
+ });
+
+ it.each(["composer_button_existing_thread", "avatar", "global_hotkey"])(
+ "preserves %s and its locator even with an external draft",
+ (entry) => {
+ const original = {
+ ...request(),
+ source: entry,
+ locator: { hostId: "local", conversationId: "codex-1" },
+ };
+ const { routing, policy } = routingFixture();
+ expect(routeExternalVoiceLaunch(original, routing)).toBe(original);
+ expect(policy.selectedModel).not.toHaveBeenCalled();
+ },
+ );
+
+ it("preserves official requests by identity after clearing external selection", () => {
+ const { routing, policy } = routingFixture();
+ policy.selectedModel.mockReturnValue(null);
+ const original = request();
+ expect(routeExternalVoiceLaunch(original, routing)).toBe(original);
+ });
+
+ it("does not borrow another Host or ambiguous Composer's draft", () => {
+ const { routing, policy } = routingFixture();
+ const remote = { ...request(), hostId: "remote-ssh-discovered:linux" };
+ expect(routeExternalVoiceLaunch(remote, routing)).toBe(remote);
+ vi.mocked(routing.forComposer).mockReturnValue(null);
+ const local = request();
+ expect(routeExternalVoiceLaunch(local, routing)).toBe(local);
+ expect(policy.selectedModel).not.toHaveBeenCalled();
+ });
+
+ it("resolves the matching remote Host without using local selection", () => {
+ const { routing, route } = routingFixture();
+ route.hostId = "remote-ssh-discovered:linux";
+ const remote = { ...request(), hostId: route.hostId };
+ expect(routeExternalVoiceLaunch(remote, routing)).toMatchObject({
+ hostId: route.hostId,
+ activeCollaborationMode: { settings: { model: carrier } },
+ });
+ const local = request();
+ expect(routeExternalVoiceLaunch(local, routing)).toBe(local);
+ });
+
+ it("constructs default collaboration settings when native launch has none", () => {
+ const { routing } = routingFixture();
+ expect(
+ routeExternalVoiceLaunch({ source, activeCollaborationMode: null }, routing),
+ ).toMatchObject({
+ activeCollaborationMode: {
+ mode: "default",
+ settings: { model: carrier, reasoning_effort: null, developer_instructions: null },
+ },
+ });
+ });
+});
+
+describe("versioned native voice service hook", () => {
+ it("wraps the service reference and forwards the native RPC receiver, callbacks and return value", async () => {
+ const fixture = hookFixture();
+ const hook = installExternalVoiceLaunchHook(fixture);
+ await settle();
+ expect(hook.status).toBe("active");
+ expect(installExternalVoiceLaunchHook(fixture)).toBe(hook);
+ const contextProvider = () => "context";
+ const result = fixture.services.realtimeVoiceRuntime.requestRealtimeStart(
+ request(),
+ "launch-1",
+ contextProvider,
+ );
+ expect(result.receiver).toBe(fixture.runtime);
+ expect(result.args).toEqual([
+ expect.objectContaining({
+ activeCollaborationMode: expect.objectContaining({
+ settings: expect.objectContaining({ model: carrier }),
+ }),
+ }),
+ "launch-1",
+ contextProvider,
+ ]);
+ expect(fixture.services.realtimeVoiceRuntime.registerRealtimeStarter).toBe(fixture.other);
+ expect(fixture.importModule).toHaveBeenCalledOnce();
+ expect(fixture.importModule).toHaveBeenCalledWith("app://-/assets/app-shared-8bd9d0399038.js");
+ hook.dispose();
+ expect(fixture.services.realtimeVoiceRuntime).toBe(fixture.runtime);
+ });
+
+ it("passes official launches unchanged and preserves RPC failures", async () => {
+ const fixture = hookFixture();
+ const hook = installExternalVoiceLaunchHook(fixture);
+ await settle();
+ fixture.policy.selectedModel.mockReturnValue(null);
+ const original = request();
+ fixture.services.realtimeVoiceRuntime.requestRealtimeStart(original);
+ expect(fixture.start.mock.calls[0]?.[0]).toBe(original);
+ fixture.start.mockImplementation(() => {
+ throw new Error("native failure");
+ });
+ expect(() => fixture.services.realtimeVoiceRuntime.requestRealtimeStart(original)).toThrow(
+ "native failure",
+ );
+ hook.dispose();
+ });
+
+ it("does not import mismatched or absent audited assets", async () => {
+ vi.useFakeTimers();
+ const fixture = hookFixture();
+ fixture.names[1] = "app-shared-different.js";
+ const hook = installExternalVoiceLaunchHook(fixture);
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(hook.status).toBe("unsupported");
+ expect(fixture.importModule).not.toHaveBeenCalled();
+ expect(fixture.services.realtimeVoiceRuntime).toBe(fixture.runtime);
+ hook.dispose();
+ });
+
+ it("waits for native services initialization after importing the audited module", async () => {
+ vi.useFakeTimers();
+ const fixture = hookFixture();
+ const module: { fN?: unknown } = {};
+ const hook = installExternalVoiceLaunchHook({ ...fixture, importModule: async () => module });
+ await settle();
+ expect(hook.status).toBe("pending");
+ module.fN = fixture.services;
+ await vi.advanceTimersByTimeAsync(250);
+ expect(hook.status).toBe("active");
+ hook.dispose();
+ });
+
+ it("does not publish a hook after disposal during module import", async () => {
+ const fixture = hookFixture();
+ let resolve!: (value: Record) => void;
+ const pending = new Promise>((done) => {
+ resolve = done;
+ });
+ const hook = installExternalVoiceLaunchHook({ ...fixture, importModule: () => pending });
+ hook.dispose();
+ resolve({ fN: fixture.services });
+ await settle();
+ expect(fixture.services.realtimeVoiceRuntime).toBe(fixture.runtime);
+ });
+});
diff --git a/packages/renderer-extension/test/renderer-host-routing.test.ts b/packages/renderer-extension/test/renderer-host-routing.test.ts
index 176aa34fd..0be9fe234 100644
--- a/packages/renderer-extension/test/renderer-host-routing.test.ts
+++ b/packages/renderer-extension/test/renderer-host-routing.test.ts
@@ -6,6 +6,7 @@ import {
} from "@codexhost/shared-contracts";
import { afterEach, assert, expect, it, vi } from "vitest";
import { installCurrentRendererAdapter } from "../src/versioned-renderer-adapter.js";
+import { routeExternalVoiceLaunch } from "../src/renderer-external-voice-launch.js";
const remoteId = "remote-ssh-discovered:linux";
const piRequest = { harnessId: harnessIdSchema.parse("pi") };
@@ -106,6 +107,32 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+it("carries the adapter's selected Harness across native new-voice launch and clears for Codex", async () => {
+ const fixture = await setup("local");
+ try {
+ const request = {
+ source: "composer_button_new_thread",
+ activeCollaborationMode: { mode: "default", settings: { model: "gpt-6.1-sol" } },
+ };
+ expect(fixture.adapter.applyAgent("grok")).toBe(true);
+ const expected = encodeHarnessPluginRoute({ harnessId: harnessIdSchema.parse("grok") });
+ const overlayRequest = structuredClone(
+ routeExternalVoiceLaunch(request, window.__codexhostHostRoutingV1),
+ );
+ expect(overlayRequest).toMatchObject({
+ activeCollaborationMode: { settings: { model: expected } },
+ });
+ expect(fixture.adapter.applyAgent("codex")).toBe(true);
+ expect(routeExternalVoiceLaunch(request, window.__codexhostHostRoutingV1)).toBe(request);
+ // An owner replacement retires its selection instead of leaking to the new connection.
+ expect(fixture.adapter.applyAgent("grok")).toBe(true);
+ fixture.managers.delete("local");
+ expect(routeExternalVoiceLaunch(request, window.__codexhostHostRoutingV1)).toBe(request);
+ } finally {
+ fixture.adapter.dispose();
+ }
+});
+
it.each(["local", remoteId])(
"queries both Hosts and follows switches immediately when %s opens first",
async (hostId) => {
diff --git a/packages/renderer-extension/test/renderer-overlay-thread-isolation.test.ts b/packages/renderer-extension/test/renderer-overlay-thread-isolation.test.ts
new file mode 100644
index 000000000..b7c2180c8
--- /dev/null
+++ b/packages/renderer-extension/test/renderer-overlay-thread-isolation.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ installOverlayThreadIsolation,
+ isAvatarOverlayHref,
+ notificationThreadId,
+ overlayBlocksHostNotification,
+ type OverlayIsolationMessageEvent,
+ type OverlayIsolationTarget,
+} from "../src/renderer-overlay-thread-isolation.js";
+
+class FakeOverlayWindow implements OverlayIsolationTarget {
+ readonly listeners = new Map void>>();
+ constructor(readonly location: { href: string }) {}
+
+ addEventListener(
+ type: "message",
+ listener: (event: OverlayIsolationMessageEvent) => void,
+ options?: boolean | { capture?: boolean },
+ ): void {
+ if (type !== "message") return;
+ const capture = options === true || (typeof options === "object" && options.capture === true);
+ const set = this.listeners.get(capture) ?? new Set();
+ set.add(listener);
+ this.listeners.set(capture, set);
+ }
+
+ removeEventListener(
+ type: "message",
+ listener: (event: OverlayIsolationMessageEvent) => void,
+ options?: boolean | { capture?: boolean },
+ ): void {
+ const capture = options === true || (typeof options === "object" && options.capture === true);
+ this.listeners.get(capture)?.delete(listener);
+ }
+
+ dispatch(data: unknown): boolean {
+ let stopped = false;
+ const event: OverlayIsolationMessageEvent = {
+ data,
+ stopImmediatePropagation() {
+ stopped = true;
+ },
+ };
+ for (const listener of this.listeners.get(true) ?? []) {
+ listener(event);
+ if (stopped) return true;
+ }
+ for (const listener of this.listeners.get(false) ?? []) {
+ listener(event);
+ if (stopped) return true;
+ }
+ return false;
+ }
+}
+
+const overlayHref = "app://-/index.html?initialRoute=%2Favatar-overlay";
+
+describe("overlay thread isolation", () => {
+ it("recognizes avatar overlay hrefs", () => {
+ expect(isAvatarOverlayHref(overlayHref)).toBe(true);
+ expect(isAvatarOverlayHref("app://-/avatar-overlay.html")).toBe(true);
+ expect(isAvatarOverlayHref("app://-/index.html")).toBe(false);
+ });
+
+ it("blocks leftover overlay from ingesting another Thread's live work", () => {
+ expect(overlayBlocksHostNotification("thread/started", "cli-thread", new Set())).toBe(true);
+ expect(overlayBlocksHostNotification("turn/started", "cli-thread", new Set())).toBe(true);
+ expect(overlayBlocksHostNotification("item/completed", "cli-thread", new Set())).toBe(true);
+ expect(
+ overlayBlocksHostNotification("thread/realtime/started", "voice-thread", new Set()),
+ ).toBe(false);
+ expect(
+ overlayBlocksHostNotification("item/completed", "voice-thread", new Set(["voice-thread"])),
+ ).toBe(false);
+ });
+
+ it("reads threadId from notification params", () => {
+ expect(notificationThreadId({ threadId: "abc" })).toBe("abc");
+ expect(notificationThreadId({ thread: { id: "def" } })).toBe("def");
+ expect(notificationThreadId({})).toBeUndefined();
+ });
+
+ it("does not install on the primary window", () => {
+ const primary = new FakeOverlayWindow({ href: "app://-/index.html" });
+ const hook = installOverlayThreadIsolation(primary);
+ expect(hook.status).toBe("disabled");
+ expect(
+ primary.dispatch({
+ type: "mcp-notification",
+ method: "thread/started",
+ params: { thread: { id: "cli" } },
+ }),
+ ).toBe(false);
+ });
+
+ it("stops CLI thread/started on leftover overlay and allows the active realtime Thread", () => {
+ const overlay = new FakeOverlayWindow({ href: overlayHref });
+ const hook = installOverlayThreadIsolation(overlay);
+ expect(hook.status).toBe("active");
+ expect(
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "thread/started",
+ params: { thread: { id: "cli-thread" } },
+ }),
+ ).toBe(true);
+ expect(
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "thread/realtime/started",
+ params: { threadId: "voice-thread" },
+ }),
+ ).toBe(false);
+ expect(
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "item/completed",
+ params: { threadId: "voice-thread" },
+ }),
+ ).toBe(false);
+ expect(
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "item/completed",
+ params: { threadId: "cli-thread" },
+ }),
+ ).toBe(true);
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "thread/realtime/closed",
+ params: { threadId: "voice-thread" },
+ });
+ expect(
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "item/completed",
+ params: { threadId: "voice-thread" },
+ }),
+ ).toBe(true);
+ hook.dispose();
+ });
+});
From 3d1e80424e00e763b97dd056ac72701bf7973963 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 04:02:25 +0900
Subject: [PATCH 12/98] fix: drop unused import in external sandbox test
---
.../host-runtime/test/app-server-host.external-sandbox.test.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/host-runtime/test/app-server-host.external-sandbox.test.ts b/packages/host-runtime/test/app-server-host.external-sandbox.test.ts
index 768f2db62..763175c9d 100644
--- a/packages/host-runtime/test/app-server-host.external-sandbox.test.ts
+++ b/packages/host-runtime/test/app-server-host.external-sandbox.test.ts
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
-import type { JsonObject } from "@codexhost/shared-contracts";
import {
completePiTurn,
createFixture,
From 7472cc86546e0ea58491aff2388d7792a5451e6a Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 04:02:26 +0900
Subject: [PATCH 13/98] chore: prepare v0.13.3-0
---
Cargo.lock | 10 +++++-----
Cargo.toml | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 8ac6b8e8d..b72a79083 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "codexhost-gate-a-native"
-version = "0.13.3"
+version = "0.13.3-0"
dependencies = [
"codexhost-platform",
"codexhost-shim",
@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "codexhost-launcher"
-version = "0.13.3"
+version = "0.13.3-0"
dependencies = [
"codexhost-platform",
"fs2",
@@ -106,7 +106,7 @@ dependencies = [
[[package]]
name = "codexhost-platform"
-version = "0.13.3"
+version = "0.13.3-0"
dependencies = [
"libproc",
"nix",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "codexhost-shim"
-version = "0.13.3"
+version = "0.13.3-0"
dependencies = [
"codexhost-platform",
"fs2",
@@ -131,7 +131,7 @@ dependencies = [
[[package]]
name = "codexhost-updater"
-version = "0.13.3"
+version = "0.13.3-0"
dependencies = [
"codexhost-platform",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index c383ca060..a540eb25c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ default-members = [
resolver = "3"
[workspace.package]
-version = "0.13.3"
+version = "0.13.3-0"
edition = "2024"
rust-version = "1.97.1"
publish = false
diff --git a/package-lock.json b/package-lock.json
index ea8a0d865..139eaaf39 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexhost",
- "version": "0.13.3",
+ "version": "0.13.3-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexhost",
- "version": "0.13.3",
+ "version": "0.13.3-0",
"license": "LGPL-3.0-only",
"workspaces": [
"packages/*",
diff --git a/package.json b/package.json
index ad144f423..64461496b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexhost",
- "version": "0.13.3",
+ "version": "0.13.3-0",
"private": true,
"license": "LGPL-3.0-only",
"type": "module",
From 5d6ac771d9766c0770f6f5621b8c7509d4172da1 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 04:06:33 +0900
Subject: [PATCH 14/98] test: allow smol-toml in the Host runtime bundle
---
tests/release/host-bundle.test.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/release/host-bundle.test.mjs b/tests/release/host-bundle.test.mjs
index ea09c9ded..ab5e7aba4 100644
--- a/tests/release/host-bundle.test.mjs
+++ b/tests/release/host-bundle.test.mjs
@@ -176,6 +176,7 @@ describe("release Host and independent plugin Bundles", () => {
"@deepseek-ai/cordis",
"@deepseek-ai/cosmokit",
"diff",
+ "smol-toml",
"ws",
"zod",
]);
From 710f7679ed0a9c2ddde9fbc5a3975b827587a195 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 05:54:07 +0900
Subject: [PATCH 15/98] feat: send Grok Desktop session rules through ACP
_meta.rules
Stop installing ~/.grok/rules/codexhost-desktop.md. Pass the rules on
Grok session/new and session/load, and retire leftover Host-owned copies.
---
README.md | 2 +
.../harness-plugin-architecture.md | 2 +-
.../operations/cpa-live-voice-desktop-hook.md | 25 ++-
docs/project/README.ko.md | 2 +
docs/project/README.zh-CN.md | 2 +
packages/adapters/grok/src/acp-transport.ts | 8 +-
.../grok/src/grok-codexhost-session-rules.ts | 37 +++++
packages/adapters/grok/src/index.ts | 4 +
.../grok/test/grok-acp-session-rules.test.ts | 131 +++++++++++++++
.../test/grok-codexhost-session-rules.test.ts | 27 +++
.../src/codexhost-desktop-rule.ts | 157 ++++--------------
packages/host-runtime/src/index.ts | 2 +-
packages/host-runtime/src/run-host-runtime.ts | 4 +-
.../test/codexhost-desktop-rule.test.ts | 110 ++++--------
.../test/fixtures/codexhost-desktop-v3.md | 32 ++++
15 files changed, 316 insertions(+), 229 deletions(-)
create mode 100644 packages/adapters/grok/src/grok-codexhost-session-rules.ts
create mode 100644 packages/adapters/grok/test/grok-acp-session-rules.test.ts
create mode 100644 packages/adapters/grok/test/grok-codexhost-session-rules.test.ts
create mode 100644 packages/host-runtime/test/fixtures/codexhost-desktop-v3.md
diff --git a/README.md b/README.md
index 2b9da882d..42add5e52 100644
--- a/README.md
+++ b/README.md
@@ -313,6 +313,8 @@ Most of the work is implementing the plugin's Manifest, factory, Adapter, and Se
Tip: point your coding Agent at the in-repo [codexhost-add-harness Skill](.agents/skills/codexhost-add-harness/SKILL.md). It covers plugin structure, the shared Adapter interface, capability implementation, and testing requirements.
+Desktop Grok Threads receive CodexHost session rules through Grok's official ACP `session/new` `_meta.rules` field. Host Runtime retires leftover Host-owned `~/.grok/rules/codexhost-desktop.md` copies and leaves user-edited files in place. New Harness plugins do not add a home-rule copy. Details: [operations/cpa-live-voice-desktop-hook.md](docs/operations/cpa-live-voice-desktop-hook.md).
+
## Acknowledgements
- Thanks to the [LINUX DO](https://linux.do/) community for their ongoing support.
diff --git a/docs/architecture/harness-plugin-architecture.md b/docs/architecture/harness-plugin-architecture.md
index 7dcb9ebd3..f53ecab51 100644
--- a/docs/architecture/harness-plugin-architecture.md
+++ b/docs/architecture/harness-plugin-architecture.md
@@ -107,7 +107,7 @@ Codex Desktop 协议、官方 app-server、Renderer 兼容绑定仍是 codexhost
- [`run-host-runtime.ts`](../../packages/host-runtime/src/run-host-runtime.ts) 显式预取 Claude/Antigravity 目录。
- [`harness-broker`](../../packages/harness-broker/src/protocol.ts) 的描述、客户端与服务端实际限定 Claude Code,不是已经通用的插件进程协议。
- [`delegation-skill.ts`](../../packages/host-runtime/src/delegation-skill.ts) 包含 `.claude` 安装位置。
-- [`codexhost-desktop-rule.ts`](../../packages/host-runtime/src/codexhost-desktop-rule.ts) 把 Codex Desktop / CodexHost 会话规则安装到 `~/.grok/rules`。
+- [`codexhost-desktop-rule.ts`](../../packages/host-runtime/src/codexhost-desktop-rule.ts) 只回收 Host 曾经写入的 `~/.grok/rules/codexhost-desktop.md`;Grok 会话规则走 Adapter 的 ACP `_meta.rules`。
- 基线中的 [`remote-host-install.ts`](../../packages/host-runtime/src/remote-host-install.ts) 曾固化 `claudeCommand`;该安装与启动特例现已移除,Harness 可执行文件发现仍由 Adapter 负责。
- 发布脚本维护各原生 SDK 与许可证。
diff --git a/docs/operations/cpa-live-voice-desktop-hook.md b/docs/operations/cpa-live-voice-desktop-hook.md
index 8c4996b1f..16b883f11 100644
--- a/docs/operations/cpa-live-voice-desktop-hook.md
+++ b/docs/operations/cpa-live-voice-desktop-hook.md
@@ -124,23 +124,20 @@ hangup. These are isolated headless-page trials, not installed Desktop button
acceptance or proof that every plugin is voice-compatible. Continuous conversation,
speech interruption, task cancellation and approvals need attended acceptance.
-## Harness session rules (Grok home rule)
+## Harness session rules (Grok ACP `_meta.rules`)
-Host Runtime installs `codexhost-desktop.md` into `~/.grok/rules/` only. It does
-not write `~/.claude/rules/`. A previously managed Claude copy is removed when
-its digest still matches a Host-owned version; a user-edited Claude copy is
-preserved.
+The Grok Adapter passes Desktop session rules through Grok's official ACP
+`session/new` and `session/load` `_meta.rules` field. Grok appends that text in
+a `` block. Host Runtime does not write `~/.grok/rules/` or
+`~/.claude/rules/`. On startup it removes Host-owned copies of
+`codexhost-desktop.md` whose content still matches a previous managed digest;
+a user-edited copy is preserved. TUI and Orca Grok sessions therefore stay
+free of that file unless the user kept an edited copy.
-Grok home rules are always scanned. At the first user turn the agent runs a
-secret-free `CODEXHOST_THREAD_ID` set/unset check. `set` means this is a
-CodexHost external Thread, including a Desktop chat whose cwd is a real
-repository. A Playground `realtime-voice-chat*` cwd also activates the rule and
-asks for a new Thread in the real repo. `unset` outside that Playground cwd
-leaves the six Desktop rules inactive. The agent never prints tokens, endpoints,
-or runtime descriptors.
+Grok appends that text to `system_prompt.txt` inside ``. The
+agent never prints tokens, endpoints, or runtime descriptors.
The rule covers composer Model selection (including that `flash-max` is Gemini
Flash), starting in the real repo, voice versus text tool work, per-Harness MCP
and skills, Codex Desktop chrome / Markdown View, and official Codex live voice
-versus Host-owned external `thread/realtime/*`. A user-edited Grok copy is
-preserved.
+versus Host-owned external `thread/realtime/*`.
diff --git a/docs/project/README.ko.md b/docs/project/README.ko.md
index 75b75ce48..c23a97249 100644
--- a/docs/project/README.ko.md
+++ b/docs/project/README.ko.md
@@ -306,6 +306,8 @@ Pi를 예로 듭니다. 왼쪽에서 오른쪽이 한 번의 요청 호출 체
주요 작업은 플러그인의 Manifest, 팩토리, Adapter, Session 및 네이티브 통신과 변환 로직을 구현하는 것입니다. 현재 Renderer에는 여전히 정적 연결이 있어, 완전한 Desktop 연동은 별도로 처리해야 합니다.
Harness를 추가할 때는 코딩 Agent가 저장소의 [codexhost-add-harness Skill](../../.agents/skills/codexhost-add-harness/SKILL.md)을 사용하도록 할 수 있습니다. 플러그인 구조, 공용 Adapter 인터페이스, 기능 구현과 테스트 요구 사항을 설명합니다.
+Desktop Grok Thread는 Grok 공식 ACP `session/new` `_meta.rules`로 CodexHost 세션 규칙을 받습니다. Host Runtime은 이전에 설치한 `~/.grok/rules/codexhost-desktop.md` 중 Host 소유 사본만 회수하고, 사용자가 고친 파일은 남깁니다. 새 Harness 플러그인은 home rule을 추가하지 않습니다. 설명: [operations/cpa-live-voice-desktop-hook.md](../operations/cpa-live-voice-desktop-hook.md).
+
## 감사의 글
- 지속적인 지원을 보내 주신 [LINUX DO](https://linux.do/) 커뮤니티에 감사드립니다.
diff --git a/docs/project/README.zh-CN.md b/docs/project/README.zh-CN.md
index dd4cd3db2..6924e7dec 100644
--- a/docs/project/README.zh-CN.md
+++ b/docs/project/README.zh-CN.md
@@ -312,6 +312,8 @@ npm start
主要实现插件的 Manifest、工厂、Adapter、Session 及原生通信与转换逻辑。当前 Renderer 仍有静态接线,完整 Desktop 接入还需单独处理。
新增 Harness 时,可以让编码 Agent 使用仓库内的 [codexhost-add-harness Skill](../../.agents/skills/codexhost-add-harness/SKILL.md)。它说明了插件结构、公共 Adapter 接口、能力实现与测试要求。
+Desktop Grok Thread 通过 Grok 官方 ACP `session/new` 的 `_meta.rules` 接收 CodexHost 会话规则。Host Runtime 会回收仍匹配历史摘要的 `~/.grok/rules/codexhost-desktop.md`,并保留用户改过的副本。新 Harness 插件不要再写 home rule。说明见 [operations/cpa-live-voice-desktop-hook.md](../operations/cpa-live-voice-desktop-hook.md)。
+
## 鸣谢
- 感谢 [LINUX DO](https://linux.do/) 社区一直以来的支持。
diff --git a/packages/adapters/grok/src/acp-transport.ts b/packages/adapters/grok/src/acp-transport.ts
index 6d321722d..c9d7122ec 100644
--- a/packages/adapters/grok/src/acp-transport.ts
+++ b/packages/adapters/grok/src/acp-transport.ts
@@ -48,7 +48,8 @@ import {
parseGrokRewindResponse,
type GrokRewindParams,
} from "./grok-rewind.js";
-import { decodeGrokPermissionModeId, grokPermissionModeSessionMeta } from "./permission-modes.js";
+import { grokCodexhostSessionMeta } from "./grok-codexhost-session-rules.js";
+import { decodeGrokPermissionModeId } from "./permission-modes.js";
export type GrokTransportFaultKind =
"notInstalled" | "authenticationRequired" | "unavailable" | "protocolError" | "processExited";
@@ -642,7 +643,7 @@ export class GrokAcpTransport {
connection.newSession({
cwd: this.#options.cwd,
mcpServers: [],
- _meta: grokPermissionModeSessionMeta(permissionMode),
+ _meta: grokCodexhostSessionMeta(permissionMode),
}),
this.#options.commandTimeoutMs,
"Grok Session creation",
@@ -664,6 +665,7 @@ export class GrokAcpTransport {
cwd: this.#options.cwd,
mcpServers: [],
sessionId: forked.newSessionId,
+ _meta: grokCodexhostSessionMeta(),
}),
this.#options.commandTimeoutMs,
"Grok Session load",
@@ -684,7 +686,7 @@ export class GrokAcpTransport {
cwd: this.#options.cwd,
mcpServers: [],
sessionId: input.sessionId,
- ...(permissionMode ? { _meta: grokPermissionModeSessionMeta(permissionMode) } : {}),
+ _meta: grokCodexhostSessionMeta(permissionMode),
}),
this.#options.commandTimeoutMs,
"Grok Session load",
diff --git a/packages/adapters/grok/src/grok-codexhost-session-rules.ts b/packages/adapters/grok/src/grok-codexhost-session-rules.ts
new file mode 100644
index 000000000..836f5051e
--- /dev/null
+++ b/packages/adapters/grok/src/grok-codexhost-session-rules.ts
@@ -0,0 +1,37 @@
+import type { GrokPermissionMode } from "./permission-modes.js";
+import { grokPermissionModeSessionMeta } from "./permission-modes.js";
+
+/** Official Grok ACP `session/new` `_meta.rules` text. CodexHost Grok sessions only. */
+export const GROK_CODEXHOST_SESSION_RULES = `# Codex Desktop / CodexHost
+
+CodexHost started this Grok ACP session as an external Thread inside Codex Desktop.
+
+Never print \`CODEXHOST_THREAD_ID\`, \`CODEXHOST_RUNTIME_TOKEN\`, \`CODEXHOST_RUNTIME_ENDPOINT\`, or other runtime descriptors.
+
+## Session rules
+
+1. **Model.** The Desktop composer Model is this Harness's Model. Pick that Harness's real Model before starting voice. For Grok, that is grok-4.6 or grok-4.7. \`flash-max\` is a Gemini Flash Model.
+
+2. **Working directory.** Start the chat in the real repository (\`codex-host\` or the wiki). A Playground \`realtime-voice-chat*\` folder is a scratch cwd. Ask the user to start a new Thread in the real repo.
+
+3. **Voice vs tools.** Desktop voice is gpt-live speech plus delegated Harness tasks on this same Thread. Do wiki, MCP, and file-heavy work as text in this Thread.
+
+4. **Harness-owned tools.** This session's MCP servers and skills belong to this Harness. SkillWiki on Grok is Grok's config, not Claude, Pi, or Codex.
+
+5. **Desktop chrome.** The window is Codex Desktop. Markdown View preview is a Codex file tab (View preview vs View source). \`file://\` browser tabs stay raw.
+
+6. **Voice routes.** Official Codex live voice (New API/CPA) is a separate route from Host-owned external \`thread/realtime/*\`. This external Thread uses the Host-owned route.
+
+Keep Grok Build tools, MCP, and rules inside the native session. An \`AGENT_TOOLS_AND_RULES.md\` dump in the cwd is unnecessary.
+`;
+
+export function grokCodexhostSessionMeta(permissionMode?: GrokPermissionMode): {
+ rules: string;
+ yoloMode?: boolean;
+ autoMode?: boolean;
+} {
+ return {
+ ...(permissionMode ? grokPermissionModeSessionMeta(permissionMode) : {}),
+ rules: GROK_CODEXHOST_SESSION_RULES,
+ };
+}
diff --git a/packages/adapters/grok/src/index.ts b/packages/adapters/grok/src/index.ts
index 22a8d20da..1aabc70a3 100644
--- a/packages/adapters/grok/src/index.ts
+++ b/packages/adapters/grok/src/index.ts
@@ -38,6 +38,10 @@ export {
decodeGrokPermissionModeId,
grokPermissionModeSessionMeta,
} from "./permission-modes.js";
+export {
+ GROK_CODEXHOST_SESSION_RULES,
+ grokCodexhostSessionMeta,
+} from "./grok-codexhost-session-rules.js";
export type { GrokPermissionMode } from "./permission-modes.js";
export {
GROK_REWIND_EXECUTE_METHOD,
diff --git a/packages/adapters/grok/test/grok-acp-session-rules.test.ts b/packages/adapters/grok/test/grok-acp-session-rules.test.ts
new file mode 100644
index 000000000..5407aacd9
--- /dev/null
+++ b/packages/adapters/grok/test/grok-acp-session-rules.test.ts
@@ -0,0 +1,131 @@
+import { randomUUID } from "node:crypto";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+import { harnessPermissionModeIdSchema } from "@codexhost/shared-contracts";
+import { describe, expect, it } from "vitest";
+
+import { GrokAcpTransport } from "../src/acp-transport.js";
+import {
+ GROK_CODEXHOST_SESSION_RULES,
+ grokCodexhostSessionMeta,
+} from "../src/grok-codexhost-session-rules.js";
+
+const permissionModeId = (id: "ask" | "auto" | "always-approve") =>
+ harnessPermissionModeIdSchema.parse(id);
+
+async function capturedMeta(
+ root: string,
+ open: Parameters[0],
+): Promise[]> {
+ const command = path.join(root, "fixture.mjs");
+ const capture = path.join(root, `captured-${randomUUID()}.jsonl`);
+ await writeFile(
+ command,
+ `#!${process.execPath}
+import { appendFileSync } from 'node:fs';
+import { createInterface } from 'node:readline';
+const capture = process.env.CODEXHOST_ACP_CAPTURE;
+const send = (value) => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\\n');
+createInterface({input:process.stdin}).on('line', line => {
+ const request = JSON.parse(line);
+ if (request.method === 'session/new' || request.method === 'session/load') {
+ appendFileSync(capture, JSON.stringify({method:request.method, params:request.params})+'\\n');
+ }
+ if (request.method === 'initialize') {
+ send({id:request.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}});
+ } else if (request.method === 'session/new') {
+ send({id:request.id,result:{sessionId:'created'}});
+ } else if (request.method === 'session/load') {
+ send({id:request.id,result:{sessionId:request.params.sessionId}});
+ } else if (request.method === '_x.ai/session/fork') {
+ send({id:request.id,result:{newSessionId:'forked',parentSessionId:request.params.sourceSessionId}});
+ } else if (request.method === '_x.ai/rewind/execute') {
+ send({id:request.id,result:{success:true,targetPromptIndex:request.params.targetPromptIndex}});
+ }
+});
+`,
+ { mode: 0o755 },
+ );
+ const transport = new GrokAcpTransport({
+ cwd: root,
+ command,
+ environment: { HOME: root, GROK_HOME: root, CODEXHOST_ACP_CAPTURE: capture },
+ commandTimeoutMs: 3000,
+ closeTimeoutMs: 100,
+ });
+ try {
+ await transport.open(open);
+ } finally {
+ await transport.close();
+ }
+ const text = await readFile(capture, "utf8");
+ return text
+ .trim()
+ .split("\n")
+ .map(
+ (line) => JSON.parse(line) as { method: string; params: { _meta?: Record } },
+ )
+ .map((entry) => entry.params._meta ?? {});
+}
+
+describe.skipIf(process.platform === "win32")("Grok ACP session rules wire", () => {
+ it("sends _meta.rules on create, resume, fork-load, and rewind-load", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "grok-acp-session-rules-"));
+ try {
+ const createAsk = await capturedMeta(root, {
+ kind: "create",
+ permissionModeId: permissionModeId("ask"),
+ });
+ const createAuto = await capturedMeta(root, {
+ kind: "create",
+ permissionModeId: permissionModeId("auto"),
+ });
+ const createAlways = await capturedMeta(root, {
+ kind: "create",
+ permissionModeId: permissionModeId("always-approve"),
+ });
+ const resumeAsk = await capturedMeta(root, {
+ kind: "resume",
+ sessionId: "resume-ask",
+ permissionModeId: permissionModeId("ask"),
+ });
+ const resumeAuto = await capturedMeta(root, {
+ kind: "resume",
+ sessionId: "resume-auto",
+ permissionModeId: permissionModeId("auto"),
+ });
+ const resumeAlways = await capturedMeta(root, {
+ kind: "resume",
+ sessionId: "resume-always",
+ permissionModeId: permissionModeId("always-approve"),
+ });
+ const forkLoad = await capturedMeta(root, {
+ kind: "fork",
+ sourceSessionId: "source",
+ sourceCwd: root,
+ targetPromptIndex: 0,
+ });
+ const rewindLoad = await capturedMeta(root, {
+ kind: "rewind",
+ sessionId: "rewind",
+ targetPromptIndex: 0,
+ });
+
+ expect(createAsk).toEqual([grokCodexhostSessionMeta("ask")]);
+ expect(createAuto).toEqual([grokCodexhostSessionMeta("auto")]);
+ expect(createAlways).toEqual([grokCodexhostSessionMeta("always-approve")]);
+ expect(resumeAsk).toEqual([grokCodexhostSessionMeta("ask")]);
+ expect(resumeAuto).toEqual([grokCodexhostSessionMeta("auto")]);
+ expect(resumeAlways).toEqual([grokCodexhostSessionMeta("always-approve")]);
+ expect(forkLoad).toEqual([grokCodexhostSessionMeta()]);
+ expect(rewindLoad).toEqual([grokCodexhostSessionMeta()]);
+ expect(forkLoad[0]?.yoloMode).toBeUndefined();
+ expect(rewindLoad[0]?.autoMode).toBeUndefined();
+ expect(createAsk[0]?.rules).toBe(GROK_CODEXHOST_SESSION_RULES);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/adapters/grok/test/grok-codexhost-session-rules.test.ts b/packages/adapters/grok/test/grok-codexhost-session-rules.test.ts
new file mode 100644
index 000000000..754418f32
--- /dev/null
+++ b/packages/adapters/grok/test/grok-codexhost-session-rules.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ GROK_CODEXHOST_SESSION_RULES,
+ grokCodexhostSessionMeta,
+} from "../src/grok-codexhost-session-rules.js";
+
+describe("Grok CodexHost ACP session rules", () => {
+ it("uses official session/new _meta.rules and keeps secrets out of the prompt text", () => {
+ expect(GROK_CODEXHOST_SESSION_RULES).toContain("CodexHost started this Grok ACP session");
+ expect(GROK_CODEXHOST_SESSION_RULES).toContain("Never print");
+ expect(GROK_CODEXHOST_SESSION_RULES).toContain("flash-max");
+ expect(GROK_CODEXHOST_SESSION_RULES).toContain("thread/realtime/*");
+ expect(GROK_CODEXHOST_SESSION_RULES).toContain("AGENT_TOOLS_AND_RULES.md");
+ expect(GROK_CODEXHOST_SESSION_RULES).not.toContain("echo $CODEXHOST_THREAD_ID");
+ expect(GROK_CODEXHOST_SESSION_RULES).not.toContain("~/.grok/rules");
+ });
+
+ it("adds rules to create metadata and keeps Permission Mode flags", () => {
+ expect(grokCodexhostSessionMeta("ask")).toEqual({
+ yoloMode: false,
+ autoMode: false,
+ rules: GROK_CODEXHOST_SESSION_RULES,
+ });
+ expect(grokCodexhostSessionMeta()).toEqual({ rules: GROK_CODEXHOST_SESSION_RULES });
+ });
+});
diff --git a/packages/host-runtime/src/codexhost-desktop-rule.ts b/packages/host-runtime/src/codexhost-desktop-rule.ts
index bea95157a..9425bab40 100644
--- a/packages/host-runtime/src/codexhost-desktop-rule.ts
+++ b/packages/host-runtime/src/codexhost-desktop-rule.ts
@@ -1,66 +1,26 @@
-import { createHash, randomUUID } from "node:crypto";
-import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
+import { createHash } from "node:crypto";
+import { readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-const RULE_VERSION = 3;
const RULE_FILE = "codexhost-desktop.md";
-const PREVIOUS_MANAGED_DIGESTS: readonly string[] = [
+
+/** Host-owned home-rule copies written before ACP `_meta.rules` replaced them. */
+const MANAGED_DIGESTS: readonly string[] = [
"5add2d2c7ea03ecbb3552b94e47da7a755909e63f30714878a5609a4e272526f",
"9d8d2762b5f90e6f10a6571651b3461dec882bd45b40f0f0debc432f4833301b",
+ "4658e68f312686e2134cd282abd9ce434d2583b0a1d23c7c3eb8bd7ec9b76cf3",
];
-export const CODEXHOST_DESKTOP_RULE = `
-# Codex Desktop / CodexHost
-
-CodexHost installs this file into Grok home rules. Orca and Grok TUI sessions may also load it.
-
-At the first user turn, if you have a shell, run this once and print only \`set\` or \`unset\`:
-
-\`\`\`sh
-if test -n "\${CODEXHOST_THREAD_ID-}"; then echo set; else echo unset; fi
-\`\`\`
-
-- \`set\`: this is a CodexHost external Thread, including Codex Desktop. Apply the session rules even when cwd is a real repository such as \`codex-host\` or the wiki.
-- Working directory is a Playground folder named \`realtime-voice-chat\` or \`realtime-voice-chat-*\`: apply the session rules and ask the user to start a new Thread in the real repo.
-- \`unset\` and not a Playground voice cwd: leave the session rules inactive.
-
-Never print \`CODEXHOST_THREAD_ID\`, \`CODEXHOST_RUNTIME_TOKEN\`, \`CODEXHOST_RUNTIME_ENDPOINT\`, or other runtime descriptors. Do not invent other host signals.
-
-## Session rules
-
-1. **Model.** The Desktop composer Model is this Harness's Model. Pick that Harness's real Model before starting voice. For Grok, that is grok-4.6 or grok-4.7. \`flash-max\` is a Gemini Flash Model.
-
-2. **Working directory.** Start the chat in the real repository (\`codex-host\` or the wiki). A Playground \`realtime-voice-chat*\` folder is a scratch cwd. Ask the user to start a new Thread in the real repo.
-
-3. **Voice vs tools.** Desktop voice is gpt-live speech plus delegated Harness tasks on this same Thread. Do wiki, MCP, and file-heavy work as text in this Thread.
-
-4. **Harness-owned tools.** This session's MCP servers and skills belong to this Harness. SkillWiki on Grok is Grok's config, not Claude, Pi, or Codex.
-
-5. **Desktop chrome.** The window is Codex Desktop. Markdown View preview is a Codex file tab (View preview vs View source). \`file://\` browser tabs stay raw.
-
-6. **Voice routes.** Official Codex live voice (New API/CPA) is a separate route from Host-owned external \`thread/realtime/*\`. This external Thread uses the Host-owned route.
-
-Keep Grok Build tools, MCP, and rules inside the native session. An \`AGENT_TOOLS_AND_RULES.md\` dump in the cwd is unnecessary.
-`;
-
-const CURRENT_DIGEST = createHash("sha256").update(CODEXHOST_DESKTOP_RULE).digest("hex");
-
function digest(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
-function managedVersion(value: string): number | null {
- const match = /codexhost-desktop-rule:\s*(\d+)/u.exec(value);
- return match ? Number(match[1]) : null;
-}
-
-function grokDestination(home: string): string {
- return path.join(home, ".grok", "rules", RULE_FILE);
-}
-
-function claudeDestination(home: string): string {
- return path.join(home, ".claude", "rules", RULE_FILE);
+function destinations(home: string): string[] {
+ return [
+ path.join(home, ".grok", "rules", RULE_FILE),
+ path.join(home, ".claude", "rules", RULE_FILE),
+ ];
}
async function readOptional(filePath: string): Promise {
@@ -72,96 +32,37 @@ async function readOptional(filePath: string): Promise {
}
}
-async function atomicWrite(filePath: string, content: string): Promise {
- await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
- const temporaryPath = path.join(path.dirname(filePath), `.${RULE_FILE}.${randomUUID()}.tmp`);
- const handle = await open(temporaryPath, "wx", 0o600);
- try {
- await handle.writeFile(content, "utf8");
- await handle.sync();
- } finally {
- await handle.close();
- }
- try {
- await rename(temporaryPath, filePath);
- } finally {
- await rm(temporaryPath, { force: true });
- }
-}
-
-export type DesktopRuleInstallStatus = "installed" | "updated" | "current" | "conflict" | "removed";
+export type DesktopRuleRetireStatus = "removed" | "absent" | "conflict";
-export interface DesktopRuleInstallResult {
+export interface DesktopRuleRetireResult {
path: string;
- status: DesktopRuleInstallStatus;
- version: number | null;
+ status: DesktopRuleRetireStatus;
digest: string | null;
}
-export async function installCodexhostDesktopRules(
+/** Remove Host-owned copies. User-edited files stay in place. Does not install a replacement. */
+export async function retireCodexhostDesktopRules(
input: {
homeDirectory?: string;
previousManagedDigests?: readonly string[];
} = {},
-): Promise {
+): Promise {
const home = input.homeDirectory ?? os.homedir();
- const knownDigests = new Set([
- CURRENT_DIGEST,
- ...PREVIOUS_MANAGED_DIGESTS,
- ...(input.previousManagedDigests ?? []),
- ]);
- const results: DesktopRuleInstallResult[] = [];
- const destination = grokDestination(home);
- const current = await readOptional(destination);
- if (current === CODEXHOST_DESKTOP_RULE) {
- results.push({
- path: destination,
- status: "current",
- version: RULE_VERSION,
- digest: CURRENT_DIGEST,
- });
- } else if (current !== null) {
+ const knownDigests = new Set([...MANAGED_DIGESTS, ...(input.previousManagedDigests ?? [])]);
+ const results: DesktopRuleRetireResult[] = [];
+ for (const destination of destinations(home)) {
+ const current = await readOptional(destination);
+ if (current === null) {
+ results.push({ path: destination, status: "absent", digest: null });
+ continue;
+ }
const currentDigest = digest(current);
- const version = managedVersion(current);
if (!knownDigests.has(currentDigest)) {
- results.push({ path: destination, status: "conflict", version, digest: currentDigest });
- } else {
- await atomicWrite(destination, CODEXHOST_DESKTOP_RULE);
- results.push({
- path: destination,
- status: "updated",
- version: RULE_VERSION,
- digest: CURRENT_DIGEST,
- });
- }
- } else {
- await atomicWrite(destination, CODEXHOST_DESKTOP_RULE);
- results.push({
- path: destination,
- status: "installed",
- version: RULE_VERSION,
- digest: CURRENT_DIGEST,
- });
- }
- const claude = claudeDestination(home);
- const claudeCurrent = await readOptional(claude);
- if (claudeCurrent !== null) {
- const claudeDigest = digest(claudeCurrent);
- const version = managedVersion(claudeCurrent);
- if (knownDigests.has(claudeDigest) || claudeCurrent === CODEXHOST_DESKTOP_RULE) {
- await rm(claude, { force: true });
- results.push({ path: claude, status: "removed", version, digest: claudeDigest });
- } else {
- results.push({ path: claude, status: "conflict", version, digest: claudeDigest });
- }
- }
- for (const result of results) {
- if (result.status === "conflict" || result.status === "removed") continue;
- const source = await readFile(result.path, "utf8");
- const metadata = await stat(result.path);
- if (!metadata.isFile() || source !== CODEXHOST_DESKTOP_RULE) {
- throw new Error(`Codex Desktop rule verification failed: ${result.path}`);
+ results.push({ path: destination, status: "conflict", digest: currentDigest });
+ continue;
}
+ await rm(destination, { force: true });
+ results.push({ path: destination, status: "removed", digest: currentDigest });
}
return results;
}
diff --git a/packages/host-runtime/src/index.ts b/packages/host-runtime/src/index.ts
index d30c82e90..bdcb8168b 100644
--- a/packages/host-runtime/src/index.ts
+++ b/packages/host-runtime/src/index.ts
@@ -38,7 +38,7 @@ export { runDelegationCli, DELEGATION_HELP } from "./delegation-cli.js";
export { DelegationControlRegistry } from "./delegation-control-registry.js";
export { startDelegationControlServer } from "./delegation-control-server.js";
export { installDelegationSkills, CODEXHOST_DELEGATION_SKILL } from "./delegation-skill.js";
-export { installCodexhostDesktopRules, CODEXHOST_DESKTOP_RULE } from "./codexhost-desktop-rule.js";
+export { retireCodexhostDesktopRules } from "./codexhost-desktop-rule.js";
export {
DELEGATION_CLI_PATH_ENV,
DELEGATION_CLI_NODE_PATH_ENV,
diff --git a/packages/host-runtime/src/run-host-runtime.ts b/packages/host-runtime/src/run-host-runtime.ts
index d8063171e..9ae37e8e7 100644
--- a/packages/host-runtime/src/run-host-runtime.ts
+++ b/packages/host-runtime/src/run-host-runtime.ts
@@ -15,7 +15,7 @@ import { createOwnedUnixBackend } from "./codex-runtime/owned-official-backends.
import { DelegationControlRegistry } from "./delegation-control-registry.js";
import { installedHarnessPluginOptions } from "./installed-harness-plugins.js";
import { startDelegationControlServer } from "./delegation-control-server.js";
-import { installCodexhostDesktopRules } from "./codexhost-desktop-rule.js";
+import { retireCodexhostDesktopRules } from "./codexhost-desktop-rule.js";
import { installDelegationSkills } from "./delegation-skill.js";
import type { DelegationControlRegistration } from "./delegation-types.js";
import { readRemoteDelegationThread } from "./remote-delegation-read.js";
@@ -184,7 +184,7 @@ async function prepareDelegationRuntime(input: {
[DELEGATION_RUNTIME_TOKEN_ENV]: token,
};
await installManagedHomeFiles(installDelegationSkills, "delegation Skill");
- await installManagedHomeFiles(installCodexhostDesktopRules, "Desktop rule");
+ await installManagedHomeFiles(retireCodexhostDesktopRules, "Desktop rule retirement");
try {
return await input.createHost(environment, (value) => registry.register(value), registry);
} finally {
diff --git a/packages/host-runtime/test/codexhost-desktop-rule.test.ts b/packages/host-runtime/test/codexhost-desktop-rule.test.ts
index 5a1256686..5c5553a6f 100644
--- a/packages/host-runtime/test/codexhost-desktop-rule.test.ts
+++ b/packages/host-runtime/test/codexhost-desktop-rule.test.ts
@@ -4,10 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
-import {
- CODEXHOST_DESKTOP_RULE,
- installCodexhostDesktopRules,
-} from "../src/codexhost-desktop-rule.js";
+import { retireCodexhostDesktopRules } from "../src/codexhost-desktop-rule.js";
async function home(): Promise {
return mkdtemp(path.join(os.tmpdir(), "codexhost-desktop-rule-"));
@@ -21,97 +18,50 @@ function claudePath(root: string): string {
return path.join(root, ".claude", "rules", "codexhost-desktop.md");
}
-describe("Codex Desktop rule installation", () => {
- it("installs only the Grok home rule", async () => {
+describe("Codex Desktop home-rule retirement", () => {
+ it("reports absent when no copies exist and does not create them", async () => {
const root = await home();
- const results = await installCodexhostDesktopRules({ homeDirectory: root });
- expect(results.map((result) => result.status)).toEqual(["installed"]);
- await expect(readFile(grokPath(root), "utf8")).resolves.toBe(CODEXHOST_DESKTOP_RULE);
+ const results = await retireCodexhostDesktopRules({ homeDirectory: root });
+ expect(results.map((result) => result.status)).toEqual(["absent", "absent"]);
+ await expect(stat(grokPath(root))).rejects.toMatchObject({ code: "ENOENT" });
await expect(stat(claudePath(root))).rejects.toMatchObject({ code: "ENOENT" });
});
- it("does not rewrite a Grok copy already at the current version", async () => {
+ it("removes the shipped Grok home-rule copy and a managed Claude leftover", async () => {
const root = await home();
- await installCodexhostDesktopRules({ homeDirectory: root });
- const file = grokPath(root);
- const before = await stat(file);
- const results = await installCodexhostDesktopRules({ homeDirectory: root });
- const after = await stat(file);
- expect(results.map((result) => result.status)).toEqual(["current"]);
- expect(after.mtimeMs).toBe(before.mtimeMs);
+ const shipped = await readFile(new URL("./fixtures/codexhost-desktop-v3.md", import.meta.url), {
+ encoding: "utf8",
+ });
+ await mkdir(path.dirname(grokPath(root)), { recursive: true });
+ await mkdir(path.dirname(claudePath(root)), { recursive: true });
+ await writeFile(grokPath(root), shipped);
+ await writeFile(claudePath(root), shipped);
+ const results = await retireCodexhostDesktopRules({ homeDirectory: root });
+ expect(results.map((result) => result.status)).toEqual(["removed", "removed"]);
+ await expect(stat(grokPath(root))).rejects.toMatchObject({ code: "ENOENT" });
+ await expect(stat(claudePath(root))).rejects.toMatchObject({ code: "ENOENT" });
});
- it("updates a Grok copy whose digest matches a previous managed version", async () => {
+ it("removes a previous managed digest supplied for the upgrade case", async () => {
const root = await home();
const previous = "\nold\n";
- const destination = grokPath(root);
- await mkdir(path.dirname(destination), { recursive: true });
- await writeFile(destination, previous, "utf8");
+ await mkdir(path.dirname(grokPath(root)), { recursive: true });
+ await writeFile(grokPath(root), previous);
const { createHash } = await import("node:crypto");
- const results = await installCodexhostDesktopRules({
+ const results = await retireCodexhostDesktopRules({
homeDirectory: root,
previousManagedDigests: [createHash("sha256").update(previous).digest("hex")],
});
- expect(results.map((result) => result.status)).toEqual(["updated"]);
- await expect(readFile(destination, "utf8")).resolves.toBe(CODEXHOST_DESKTOP_RULE);
+ expect(results.map((result) => result.status)).toEqual(["removed", "absent"]);
+ await expect(stat(grokPath(root))).rejects.toMatchObject({ code: "ENOENT" });
});
- it("preserves a user-modified Grok copy", async () => {
+ it("preserves a user-edited copy", async () => {
const root = await home();
- const grok = grokPath(root);
- await mkdir(path.dirname(grok), { recursive: true });
- await writeFile(grok, "user content\n", "utf8");
- const results = await installCodexhostDesktopRules({ homeDirectory: root });
- expect(results.map((result) => result.status)).toEqual(["conflict"]);
- await expect(readFile(grok, "utf8")).resolves.toBe("user content\n");
- });
-
- it("removes a previously managed Claude copy and preserves a user-edited Claude copy", async () => {
- const root = await home();
- const claude = claudePath(root);
- await mkdir(path.dirname(claude), { recursive: true });
- const previous = "\nold\n";
- await writeFile(claude, previous, "utf8");
- const { createHash } = await import("node:crypto");
- const removed = await installCodexhostDesktopRules({
- homeDirectory: root,
- previousManagedDigests: [createHash("sha256").update(previous).digest("hex")],
- });
- expect(removed.map((result) => result.status)).toEqual(["installed", "removed"]);
- await expect(stat(claude)).rejects.toMatchObject({ code: "ENOENT" });
-
- const other = await home();
- const otherClaude = claudePath(other);
- await mkdir(path.dirname(otherClaude), { recursive: true });
- await writeFile(otherClaude, "user claude rule\n", "utf8");
- const conflict = await installCodexhostDesktopRules({ homeDirectory: other });
- expect(conflict.map((result) => result.status)).toEqual(["installed", "conflict"]);
- await expect(readFile(otherClaude, "utf8")).resolves.toBe("user claude rule\n");
- });
-
- it("fail-closes on CODEXHOST_THREAD_ID without printing secrets", () => {
- expect(CODEXHOST_DESKTOP_RULE).toContain("codexhost-desktop-rule: 3");
- expect(CODEXHOST_DESKTOP_RULE).toContain("CODEXHOST_THREAD_ID");
- expect(CODEXHOST_DESKTOP_RULE).toContain("realtime-voice-chat");
- expect(CODEXHOST_DESKTOP_RULE).toContain("leave the session rules inactive");
- expect(CODEXHOST_DESKTOP_RULE).toContain("even when cwd is a real repository");
- expect(CODEXHOST_DESKTOP_RULE).toContain("Never print");
- expect(CODEXHOST_DESKTOP_RULE).toContain("CODEXHOST_RUNTIME_TOKEN");
- expect(CODEXHOST_DESKTOP_RULE).toContain("echo set");
- expect(CODEXHOST_DESKTOP_RULE).not.toContain("echo $CODEXHOST_THREAD_ID");
- expect(CODEXHOST_DESKTOP_RULE).not.toContain("OpenPets");
- });
-
- it("captures the Desktop session rules with Harness vs Model wording", () => {
- expect(CODEXHOST_DESKTOP_RULE).toContain("this Harness's Model");
- expect(CODEXHOST_DESKTOP_RULE).toContain("flash-max");
- expect(CODEXHOST_DESKTOP_RULE).toContain("Gemini Flash");
- expect(CODEXHOST_DESKTOP_RULE).toContain("grok-4.6");
- expect(CODEXHOST_DESKTOP_RULE).toContain("Playground");
- expect(CODEXHOST_DESKTOP_RULE).toContain("gpt-live");
- expect(CODEXHOST_DESKTOP_RULE).toContain("SkillWiki on Grok");
- expect(CODEXHOST_DESKTOP_RULE).toContain("Markdown View");
- expect(CODEXHOST_DESKTOP_RULE).toContain("thread/realtime/*");
- expect(CODEXHOST_DESKTOP_RULE).toContain("AGENT_TOOLS_AND_RULES.md");
+ await mkdir(path.dirname(grokPath(root)), { recursive: true });
+ await writeFile(grokPath(root), "user content\n");
+ const results = await retireCodexhostDesktopRules({ homeDirectory: root });
+ expect(results.map((result) => result.status)).toEqual(["conflict", "absent"]);
+ await expect(readFile(grokPath(root), "utf8")).resolves.toBe("user content\n");
});
});
diff --git a/packages/host-runtime/test/fixtures/codexhost-desktop-v3.md b/packages/host-runtime/test/fixtures/codexhost-desktop-v3.md
new file mode 100644
index 000000000..87d3f5789
--- /dev/null
+++ b/packages/host-runtime/test/fixtures/codexhost-desktop-v3.md
@@ -0,0 +1,32 @@
+
+# Codex Desktop / CodexHost
+
+CodexHost installs this file into Grok home rules. Orca and Grok TUI sessions may also load it.
+
+At the first user turn, if you have a shell, run this once and print only `set` or `unset`:
+
+```sh
+if test -n "${CODEXHOST_THREAD_ID-}"; then echo set; else echo unset; fi
+```
+
+- `set`: this is a CodexHost external Thread, including Codex Desktop. Apply the session rules even when cwd is a real repository such as `codex-host` or the wiki.
+- Working directory is a Playground folder named `realtime-voice-chat` or `realtime-voice-chat-*`: apply the session rules and ask the user to start a new Thread in the real repo.
+- `unset` and not a Playground voice cwd: leave the session rules inactive.
+
+Never print `CODEXHOST_THREAD_ID`, `CODEXHOST_RUNTIME_TOKEN`, `CODEXHOST_RUNTIME_ENDPOINT`, or other runtime descriptors. Do not invent other host signals.
+
+## Session rules
+
+1. **Model.** The Desktop composer Model is this Harness's Model. Pick that Harness's real Model before starting voice. For Grok, that is grok-4.6 or grok-4.7. `flash-max` is a Gemini Flash Model.
+
+2. **Working directory.** Start the chat in the real repository (`codex-host` or the wiki). A Playground `realtime-voice-chat*` folder is a scratch cwd. Ask the user to start a new Thread in the real repo.
+
+3. **Voice vs tools.** Desktop voice is gpt-live speech plus delegated Harness tasks on this same Thread. Do wiki, MCP, and file-heavy work as text in this Thread.
+
+4. **Harness-owned tools.** This session's MCP servers and skills belong to this Harness. SkillWiki on Grok is Grok's config, not Claude, Pi, or Codex.
+
+5. **Desktop chrome.** The window is Codex Desktop. Markdown View preview is a Codex file tab (View preview vs View source). `file://` browser tabs stay raw.
+
+6. **Voice routes.** Official Codex live voice (New API/CPA) is a separate route from Host-owned external `thread/realtime/*`. This external Thread uses the Host-owned route.
+
+Keep Grok Build tools, MCP, and rules inside the native session. An `AGENT_TOOLS_AND_RULES.md` dump in the cwd is unnecessary.
From f9b57f3df97071db9b1bac5d86ac52559b7183e4 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 07:05:06 +0900
Subject: [PATCH 16/98] fix: recover leftover Desktop Controller after a failed
launch
When a previous npm start or crash leaves the Controller listening with no
Desktop, acquire launcher ownership and continue a clean launch instead of
blocking on the control endpoint.
---
crates/launcher/src/desktop_attachment.rs | 262 +++++++++++++++++-
crates/launcher/src/main.rs | 28 +-
crates/launcher/src/runtime_instance.rs | 10 +-
crates/launcher/tests/cli.rs | 4 +
.../specs/running-desktop-attachment/spec.md | 5 +
.../src/console/pages/diagnostics.ts | 1 +
.../test/console/diagnostics-page.test.ts | 1 +
7 files changed, 288 insertions(+), 23 deletions(-)
diff --git a/crates/launcher/src/desktop_attachment.rs b/crates/launcher/src/desktop_attachment.rs
index e5e84dca3..604b79207 100644
--- a/crates/launcher/src/desktop_attachment.rs
+++ b/crates/launcher/src/desktop_attachment.rs
@@ -5,11 +5,14 @@ use std::ffi::OsString;
use std::io::{self, BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
+#[cfg(target_os = "macos")]
+use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use codexhost_platform::{
DesktopInstallation, descendant_executable_exists, desktop_root_process_ids_for_installation,
+ process_snapshot, terminate_process_instance,
};
#[cfg(target_os = "windows")]
use codexhost_platform::{process_executable_path, process_exists, terminate_process_by_id};
@@ -23,6 +26,7 @@ use crate::runtime_instance::{
const ATTACHMENT_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(100);
const ATTACHMENT_RETRY_MAX_DELAY: Duration = Duration::from_secs(1);
+const ORPHANED_CONTROLLER_STOP_GRACE: Duration = Duration::from_secs(2);
#[derive(Debug)]
pub(super) struct RuntimeControl {
@@ -74,8 +78,7 @@ pub(super) fn acquire_launcher_ownership(
return Ok(LauncherOwnership::Attached);
}
if desktop_root_process_ids_for_installation(installation)?.is_empty() {
- stop_stale_launcher(descriptor)?;
- let _ = remove_matching_descriptor(&descriptor_path, descriptor)?;
+ recover_stale_controller(descriptor, &descriptor_path)?;
}
}
if let Some(guard) = try_acquire_launcher_guard(&guard_path)? {
@@ -231,6 +234,158 @@ pub(super) fn stop_stale_launcher(_descriptor: &RuntimeDescriptor) -> Result<(),
Ok(())
}
+/// Stop a Desktop Controller that outlived its Launcher and Desktop.
+///
+/// A failed or interrupted `npm start` can kill Desktop while leaving the
+/// Controller listening on the recorded attachment port. The next launch then
+/// owns the Launcher lock with no Desktop. Only a listener whose command line
+/// still carries this descriptor's attachment port and nonce is ours to stop.
+pub(super) fn recover_stale_controller(
+ descriptor: &RuntimeDescriptor,
+ descriptor_path: &Path,
+) -> Result<(), Box> {
+ stop_orphaned_controller(descriptor)?;
+ stop_stale_launcher(descriptor)?;
+ let _ = remove_matching_descriptor(descriptor_path, descriptor)?;
+ Ok(())
+}
+
+pub(super) fn stop_orphaned_controller(
+ descriptor: &RuntimeDescriptor,
+) -> Result<(), Box> {
+ let matches = matching_controller_process_ids(descriptor)?;
+ if matches.is_empty() {
+ return Ok(());
+ }
+ for process_id in &matches {
+ terminate_controller_process(*process_id, false)?;
+ }
+ if wait_for_endpoint_exit(descriptor.control_port, ORPHANED_CONTROLLER_STOP_GRACE) {
+ return Ok(());
+ }
+ for process_id in matching_controller_process_ids(descriptor)? {
+ terminate_controller_process(process_id, true)?;
+ }
+ if wait_for_endpoint_exit(descriptor.control_port, ORPHANED_CONTROLLER_STOP_GRACE) {
+ return Ok(());
+ }
+ Err(format!(
+ "leftover Desktop Controller on port {} did not exit before timeout",
+ descriptor.control_port
+ )
+ .into())
+}
+
+fn command_line_matches_controller(command: &str, descriptor: &RuntimeDescriptor) -> bool {
+ let port = descriptor.control_port.to_string();
+ let mut saw_port = false;
+ let mut saw_nonce = false;
+ let mut arguments = command.split_whitespace();
+ while let Some(argument) = arguments.next() {
+ match argument {
+ "--attachment-port" => {
+ saw_port |= arguments.next() == Some(port.as_str());
+ }
+ "--attachment-nonce" => {
+ saw_nonce |= arguments.next() == Some(descriptor.nonce.as_str());
+ }
+ _ => {}
+ }
+ }
+ saw_port && saw_nonce
+}
+
+fn matching_controller_process_ids(
+ descriptor: &RuntimeDescriptor,
+) -> Result, Box> {
+ let mut matches = Vec::new();
+ for (process_id, command) in process_command_lines()? {
+ if process_id == std::process::id() {
+ continue;
+ }
+ if command_line_matches_controller(&command, descriptor) {
+ matches.push(process_id);
+ }
+ }
+ Ok(matches)
+}
+
+fn terminate_controller_process(process_id: u32, force: bool) -> Result<(), Box> {
+ if process_id == std::process::id() {
+ return Ok(());
+ }
+ match process_snapshot(process_id) {
+ Ok(snapshot) => {
+ terminate_process_instance(&snapshot, force)?;
+ Ok(())
+ }
+ Err(_) => Ok(()),
+ }
+}
+
+fn wait_for_endpoint_exit(port: u16, timeout: Duration) -> bool {
+ let started = Instant::now();
+ loop {
+ if !endpoint_ready(port, Duration::from_millis(50)) {
+ return true;
+ }
+ if started.elapsed() >= timeout {
+ return false;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn process_command_lines() -> Result, Box> {
+ let output = Command::new("/bin/ps")
+ .args(["-axww", "-o", "pid=,command="])
+ .output()?;
+ if !output.status.success() {
+ return Err("cannot list process command lines".into());
+ }
+ Ok(String::from_utf8_lossy(&output.stdout)
+ .lines()
+ .filter_map(|line| {
+ let line = line.trim();
+ let (pid, command) = line.split_once(char::is_whitespace)?;
+ Some((pid.parse().ok()?, command.trim().to_owned()))
+ })
+ .collect())
+}
+
+#[cfg(target_os = "linux")]
+fn process_command_lines() -> Result, Box> {
+ let mut commands = Vec::new();
+ for entry in std::fs::read_dir("/proc")?.filter_map(Result::ok) {
+ let Some(process_id) = entry
+ .file_name()
+ .to_str()
+ .and_then(|name| name.parse::().ok())
+ else {
+ continue;
+ };
+ let Ok(bytes) = std::fs::read(entry.path().join("cmdline")) else {
+ continue;
+ };
+ let command = bytes
+ .split(|byte| *byte == 0)
+ .filter(|argument| !argument.is_empty())
+ .map(|argument| String::from_utf8_lossy(argument).into_owned())
+ .collect::>()
+ .join(" ");
+ if !command.is_empty() {
+ commands.push((process_id, command));
+ }
+ }
+ Ok(commands)
+}
+
+#[cfg(target_os = "windows")]
+fn process_command_lines() -> Result, Box> {
+ Ok(Vec::new())
+}
+
/// A recorded launcher PID may since have been reused by an unrelated process.
/// Only an inspected executable matching this launcher proves ownership; a PID
/// the current user cannot inspect (for example a reused SYSTEM service PID)
@@ -327,4 +482,107 @@ mod tests {
);
server.join().expect("attachment server");
}
+
+ #[test]
+ fn controller_command_matches_exact_attachment_port_and_nonce() {
+ let descriptor = descriptor(60218);
+ assert!(command_line_matches_controller(
+ "node release-main.js --attachment-port 60218 --attachment-nonce 0123456789abcdef0123456789abcdef",
+ &descriptor
+ ));
+ assert!(
+ !command_line_matches_controller(
+ "node release-main.js --attachment-port 602181 --attachment-nonce 0123456789abcdef0123456789abcdef",
+ &descriptor
+ ),
+ "port 60218 must not match 602181"
+ );
+ assert!(!command_line_matches_controller(
+ "node release-main.js --attachment-port 60218 --attachment-nonce ffffffffffffffffffffffffffffffff",
+ &descriptor
+ ));
+ assert!(!command_line_matches_controller(
+ "node console-server.mjs serve",
+ &descriptor
+ ));
+ }
+
+ #[cfg(unix)]
+ fn spawn_matching_listener(port: u16, nonce: &str) -> std::process::Child {
+ let script = format!(
+ "import socket, time\n\
+ server = socket.socket()\n\
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n\
+ server.bind(('127.0.0.1', {port}))\n\
+ server.listen(1)\n\
+ while True:\n\
+ \ttime.sleep(0.2)\n"
+ );
+ let mut command = std::process::Command::new("python3");
+ command
+ .args([
+ "-c",
+ &script,
+ "--attachment-port",
+ &port.to_string(),
+ "--attachment-nonce",
+ nonce,
+ ])
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null());
+ #[cfg(unix)]
+ {
+ use std::os::unix::process::CommandExt;
+ command.process_group(0);
+ }
+ command.spawn().expect("start leftover Controller fixture")
+ }
+
+ #[cfg(unix)]
+ fn wait_until_listening(port: u16) {
+ let started = Instant::now();
+ while !endpoint_ready(port, Duration::from_millis(50)) {
+ assert!(
+ started.elapsed() < Duration::from_secs(2),
+ "leftover Controller fixture did not listen"
+ );
+ thread::sleep(Duration::from_millis(20));
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn stop_orphaned_controller_terminates_a_matching_listener() {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).expect("reserve port");
+ let port = listener.local_addr().expect("reserved address").port();
+ drop(listener);
+ let descriptor = descriptor(port);
+ let mut child = spawn_matching_listener(port, &descriptor.nonce);
+ wait_until_listening(port);
+
+ let result = stop_orphaned_controller(&descriptor);
+ let _ = child.kill();
+ let _ = child.wait();
+ result.expect("stop leftover Controller");
+ assert!(
+ !endpoint_ready(port, Duration::from_millis(50)),
+ "matching leftover Controller kept its attachment port"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn stop_orphaned_controller_leaves_an_unrelated_listener() {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).expect("unrelated listener");
+ let port = listener.local_addr().expect("unrelated address").port();
+ let descriptor = descriptor(port);
+
+ stop_orphaned_controller(&descriptor).expect("ignore unrelated listener");
+ assert!(
+ endpoint_ready(port, Duration::from_millis(50)),
+ "unrelated listener on the recorded port was terminated"
+ );
+ drop(listener);
+ }
}
diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs
index 3c7b1fe37..8df5fa8fc 100644
--- a/crates/launcher/src/main.rs
+++ b/crates/launcher/src/main.rs
@@ -52,13 +52,14 @@ use codexhost_platform::{
use compatibility::{MAX_CONTROLLER_READINESS_LINE_BYTES, parse_controller_readiness_line};
use desktop_attachment::{
LauncherOwnership, RuntimeControl, acquire_launcher_ownership, allocate_runtime_control,
- endpoint_ready, publish_runtime_descriptor, stop_stale_launcher, wait_for_host_chain,
+ endpoint_ready, publish_runtime_descriptor, recover_stale_controller, wait_for_host_chain,
};
use installation_layout::InstalledResources;
use native_harness_broker::run_native_harness_broker_cli;
+#[cfg(target_os = "windows")]
+use runtime_instance::remove_matching_descriptor;
use runtime_instance::{
StartupObservation, StartupState, classify_startup, default_descriptor_path, read_descriptor,
- remove_matching_descriptor,
};
use startup_record::StartupOutcome;
#[cfg(target_os = "macos")]
@@ -1068,8 +1069,8 @@ fn launch(
match state {
StartupState::RecoverStale => {
if let Some(descriptor) = &descriptor {
- stop_stale_launcher(descriptor)?;
- let _ = remove_matching_descriptor(&descriptor_path, descriptor)?;
+ startup_trace("recovering leftover Desktop Controller");
+ recover_stale_controller(descriptor, &descriptor_path)?;
} else if descriptor_present {
std::fs::remove_file(&descriptor_path)?;
}
@@ -1105,14 +1106,7 @@ fn launch(
continue;
}
}
- StartupState::CleanLaunch => {
- if descriptor_present && control_endpoint_ready {
- return Err(
- "codexhost control endpoint is still active without a live Desktop; retry after it exits"
- .into(),
- );
- }
- }
+ StartupState::CleanLaunch => {}
}
let control = allocate_runtime_control()?;
@@ -1205,19 +1199,13 @@ fn launch(
}) {
StartupState::RecoverStale => {
if let Some(descriptor) = &descriptor {
- stop_stale_launcher(descriptor)?;
- let _ = remove_matching_descriptor(&descriptor_path, descriptor)?;
+ startup_trace("recovering leftover Desktop Controller");
+ recover_stale_controller(descriptor, &descriptor_path)?;
} else if descriptor_present {
return Err("codexhost runtime descriptor is invalid; remove it after checking its ownership".into());
}
}
StartupState::Attach => unreachable!("no Desktop roots were observed"),
- StartupState::CleanLaunch if descriptor_present && control_endpoint_ready => {
- return Err(
- "codexhost control endpoint is still active without a live Desktop; retry after it exits"
- .into(),
- );
- }
StartupState::CleanLaunch => {}
}
let control = allocate_runtime_control()?;
diff --git a/crates/launcher/src/runtime_instance.rs b/crates/launcher/src/runtime_instance.rs
index 42debcfd7..c88a17995 100644
--- a/crates/launcher/src/runtime_instance.rs
+++ b/crates/launcher/src/runtime_instance.rs
@@ -42,7 +42,7 @@ pub fn classify_startup(observation: StartupObservation) -> StartupState {
if observation.desktop_running {
return StartupState::Attach;
}
- if observation.descriptor_present && !observation.control_endpoint_ready {
+ if observation.descriptor_present {
return StartupState::RecoverStale;
}
StartupState::CleanLaunch
@@ -362,6 +362,14 @@ mod tests {
}),
StartupState::RecoverStale
);
+ assert_eq!(
+ classify_startup(StartupObservation {
+ desktop_running: false,
+ descriptor_present: true,
+ control_endpoint_ready: true,
+ }),
+ StartupState::RecoverStale
+ );
assert_eq!(
classify_startup(StartupObservation {
desktop_running: false,
diff --git a/crates/launcher/tests/cli.rs b/crates/launcher/tests/cli.rs
index 82fa69638..9bc4797c7 100644
--- a/crates/launcher/tests/cli.rs
+++ b/crates/launcher/tests/cli.rs
@@ -32,8 +32,12 @@ fn production_launcher_uses_the_three_state_running_desktop_flow() {
assert!(source.contains("StartupState::CleanLaunch"));
assert!(source.contains("StartupState::Attach"));
assert!(source.contains("acquire_launcher_ownership"));
+ assert!(source.contains("recover_stale_controller"));
assert!(source.contains("completely quit it before starting codexhost"));
assert!(!source.contains("attach_unmanaged_desktop"));
+ assert!(!source.contains(
+ "codexhost control endpoint is still active without a live Desktop; retry after it exits"
+ ));
}
#[test]
diff --git a/openspec/specs/running-desktop-attachment/spec.md b/openspec/specs/running-desktop-attachment/spec.md
index edfb97e3b..1874ba900 100644
--- a/openspec/specs/running-desktop-attachment/spec.md
+++ b/openspec/specs/running-desktop-attachment/spec.md
@@ -10,6 +10,11 @@ The production Launcher SHALL distinguish stale-launcher recovery, clean Desktop
- **WHEN** launcher state exists but its Desktop and control endpoint are both absent
- **THEN** Launcher MUST remove only the validated stale state and retry startup
+#### Scenario: Leftover Controller after a failed launch
+- **WHEN** exclusive Launcher ownership is acquired, no target Desktop is running, and the recorded control endpoint is still listening
+- **THEN** Launcher MUST stop only a Controller whose command line matches the recorded attachment port and nonce, remove the matching descriptor, and continue with clean launch
+- **AND** it MUST NOT terminate an unrelated listener on that port
+
#### Scenario: No Desktop is running
- **WHEN** no target Codex Desktop process exists
- **THEN** Launcher MUST use the existing clean launch with Shim, Host configuration, temporary loopback Chromium Renderer CDP, Renderer, and Controller supervision
diff --git a/packages/renderer-extension/src/console/pages/diagnostics.ts b/packages/renderer-extension/src/console/pages/diagnostics.ts
index c9f7d1245..3e5af8190 100644
--- a/packages/renderer-extension/src/console/pages/diagnostics.ts
+++ b/packages/renderer-extension/src/console/pages/diagnostics.ts
@@ -18,6 +18,7 @@ const STAGE_STEPS: Readonly> = {
"Codex Desktop installation discovered": "findDesktop",
"acquiring Launcher ownership": "ownership",
"Launcher ownership acquired": "ownership",
+ "recovering leftover Desktop Controller": "ownership",
"attached to existing controlled Desktop": "ownership",
"launching Codex Desktop": "launchDesktop",
"Codex Desktop launched": "launchDesktop",
diff --git a/packages/renderer-extension/test/console/diagnostics-page.test.ts b/packages/renderer-extension/test/console/diagnostics-page.test.ts
index db82dd6d8..d1ae2fe52 100644
--- a/packages/renderer-extension/test/console/diagnostics-page.test.ts
+++ b/packages/renderer-extension/test/console/diagnostics-page.test.ts
@@ -155,6 +155,7 @@ describe("startup failure step", () => {
it("names the step of a stage that was still waiting", () => {
expect(failedStep(stages("waiting for Desktop Controller readiness"))).toBe("connectDesktop");
expect(failedStep(stages("waiting for Host chain"))).toBe("startHost");
+ expect(failedStep(stages("recovering leftover Desktop Controller"))).toBe("ownership");
});
it("falls back to preparation for unknown or missing stages", () => {
From c489384aa71d9c20ca461aacc8f4987605c0368a Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 07:12:03 +0900
Subject: [PATCH 17/98] test: isolate Claude Code discovery from host Homebrew
The linux "not installed" case was matching /opt/homebrew/bin/claude on this
machine. Force isExecutable false so the test does not depend on the host PATH.
---
packages/adapters/claude-code/test/command.test.ts | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/packages/adapters/claude-code/test/command.test.ts b/packages/adapters/claude-code/test/command.test.ts
index 1d55dc9d2..af1c3e4f6 100644
--- a/packages/adapters/claude-code/test/command.test.ts
+++ b/packages/adapters/claude-code/test/command.test.ts
@@ -147,11 +147,14 @@ describe("Claude Code executable resolution", () => {
const homeDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codexhost-claude-home-"));
directories.push(homeDirectory);
expect(() =>
- resolveClaudeCodeExecutable({
- environment: { PATH: "" },
- homeDirectory,
- platform: "linux",
- }),
+ resolveClaudeCodeExecutable(
+ {
+ environment: { PATH: "" },
+ homeDirectory,
+ platform: "linux",
+ },
+ { isExecutable: () => false },
+ ),
).toThrow("not installed");
});
});
From 58ebea2339fb9c19b6b615daa9536d749cab116e Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 07:12:03 +0900
Subject: [PATCH 18/98] docs: count fork tags as v0.13.3-1, v0.13.3-2
Keep a single numeric counter after the upstream version. The next fork
release after v0.13.3-0 is v0.13.3-1.
---
.agents/skills/codexhost-release/SKILL.md | 4 ++--
docs/index.md | 2 +-
docs/operations/fork-versioning.md | 10 +++++-----
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.agents/skills/codexhost-release/SKILL.md b/.agents/skills/codexhost-release/SKILL.md
index a40744318..347156c04 100644
--- a/.agents/skills/codexhost-release/SKILL.md
+++ b/.agents/skills/codexhost-release/SKILL.md
@@ -17,7 +17,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
- `.github/workflows/release-packages.yml` 与 `.github/workflows/ci.yml`
- `packages/repository-automation/src/release.mjs`
- `docs/operations/repository-maintenance.md`
-- 本仓库若是 fork `karlorz/codex-host`:先读 [fork versioning](../../../docs/operations/fork-versioning.md)(`0.13.3-0` / `0.13.3-0-1`…,控制台版本同源),再读本 Skill 的上游渠道表
+- 本仓库若是 fork `karlorz/codex-host`:先读 [fork versioning](../../../docs/operations/fork-versioning.md)(`0.13.3-0` / `0.13.3-1`…,控制台版本同源),再读本 Skill 的上游渠道表
- 涉及更新提示时:`packages/update-manager/src/github-release.ts`
源码和工作流是执行行为的依据。发现指南与当前实现不一致时,说明差异;不要假装已有不支持的发布能力。
@@ -39,7 +39,7 @@ description: 发布 codexhost 正式版、预览版,编写或确认 Release No
用户意图不清楚时,先问清两个渠道的目标。最终执行前明确报告版本、npm dist-tag、GitHub 状态与是否提示更新。
-在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 为 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-0-1`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 已启用,Trusted Publisher 指向 `karlorz/codex-host`;数字计数(如 `0.13.3-0`、`0.13.3-0-1`)由工作流发成 npm `latest` + GitHub latest。控制台「codexhost 版本」必须与该准备版本一致。`gh pr create` / `gh release` 必须带 `-R karlorz/codex-host`。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
+在 `karlorz/codex-host` 上,上表的 BytePioneer npm `@codexhost/cli` / 正式 GitHub latest **不适用**。Fork 标签跟随当前 upstream 版本加计数器(upstream 为 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-1`,不要写成 `0.13.3-cpa.1`)。不向 `upstream` 推送。Fork **必须**把同一版本发布为 `@karlorz/codexhost-cli@latest`(及对应平台包),并把 `karlorz/codex-host` 上的 GitHub Release 标成 latest,这样 Settings 的 `npm install -g @karlorz/codexhost-cli@latest` 和 in-app Update 才指向 fork。禁止发布 `@codexhost/*`。`Release packages` 已启用,Trusted Publisher 指向 `karlorz/codex-host`;数字计数(如 `0.13.3-0`、`0.13.3-1`)由工作流发成 npm `latest` + GitHub latest。控制台「codexhost 版本」必须与该准备版本一致。`gh pr create` / `gh release` 必须带 `-R karlorz/codex-host`。规则全文:[fork versioning](../../../docs/operations/fork-versioning.md)。
## 2. 检查现场与验证
diff --git a/docs/index.md b/docs/index.md
index 1bd222eea..b9415d501 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -131,7 +131,7 @@
| [`operations/remote-thread-read.md`](operations/remote-thread-read.md) | 通过现有 Desktop 远程连接跨 Harness 读取会话;包含部署要求、分页、响应大小与超时限制。 |
| [`operations/host-runtime-log.md`](operations/host-runtime-log.md) | Host Runtime 日志文件的位置、内容与边界;排查 Runtime 崩溃或异常退出时阅读。 |
| [`operations/codexhost-console.md`](operations/codexhost-console.md) | 本地控制台的打开方式、页面、项目公告、与运行中 Host 的连接、端口、诊断文件、安全与更新边界;排查启动失败或维护 Web 公告时阅读。 |
-| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-0-1`…;已发 `v0.13.2-3` 为 GitHub latest 与 npm `@karlorz/codexhost-cli@latest`;数字计数走 `Release packages` latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
+| [`operations/fork-versioning.md`](operations/fork-versioning.md) | Fork `karlorz/codex-host` 标签规则:upstream 停在 `0.13.3` 时用 `v0.13.3-0`、`v0.13.3-1`…;已发 `v0.13.3-0` 为 GitHub latest 与 npm `@karlorz/codexhost-cli@latest`;数字计数走 `Release packages` latest;不向 upstream 推送、不发布 `@codexhost/*`。 |
| [`NOTICE.md`](NOTICE.md) | Web 控制台打开或刷新时读取的项目公告,含显示开关、标题、类型与正文;默认关闭,发布规则见控制台文档。 |
| [`operations/repository-maintenance.md`](operations/repository-maintenance.md) | PR 标签、CI 评论和发布前检查自动化;修改仓库自动化时阅读。 |
diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md
index a2aca2aed..cd3cfb094 100644
--- a/docs/operations/fork-versioning.md
+++ b/docs/operations/fork-versioning.md
@@ -11,10 +11,10 @@ Fork versions stay on the **upstream version that is currently held**, plus a nu
| Upstream holds | First fork tag | Later fork tags |
| --- | --- | --- |
| `0.13.2` | `v0.13.2-0` | `v0.13.2-1`, `v0.13.2-2`, `v0.13.2-3` |
-| `0.13.3` | `v0.13.3-0` | `v0.13.3-0-1`, `v0.13.3-0-2`, … |
+| `0.13.3` | `v0.13.3-0` | `v0.13.3-1`, `v0.13.3-2`, … |
- Read upstream from `upstream/main` `package.json` and the upstream GitHub latest release. If they disagree, stop and report; do not guess.
-- First fork of an upstream version is `X.Y.Z-0`. Later fork-only releases while that upstream version is still held are `X.Y.Z-0-1`, `X.Y.Z-0-2`, …. The shipped `0.13.2` tags keep their existing single-counter names.
+- First fork of an upstream version is `X.Y.Z-0`. Later fork-only releases while that upstream version is still held are `X.Y.Z-1`, `X.Y.Z-2`, …. The shipped `0.13.2` tags already use that single-counter form.
- Do **not** invent a newer minor or patch than upstream, and do **not** add a named suffix such as `0.13.3-cpa.1`. That tag is legacy and is not a fork release channel.
- `v0.13.3-0` is valid SemVer (prerelease `0` of `0.13.3`). Prepare it with `npm run release:prepare -- 0.13.3-0`.
- Numeric prerelease `0.13.3-0` compares **below** upstream `0.13.3`. The in-app updater and Settings command must therefore **not** read BytePioneer `releases/latest` or install `@codexhost/cli`. They use `karlorz/codex-host` and `@karlorz/codexhost-cli` instead.
@@ -42,9 +42,9 @@ A fork tag is not shipped until both of these exist for that version:
1. **npm:** `Release packages` publishes the packed meta package and matching platform tarballs under `@karlorz` with dist-tag `latest` (or `next` / `test` only for named SemVer prereleases). `scripts/release/publish-npm.mjs` fails closed if a plan entry is not `@karlorz/`. After CI logs `+ @karlorz/…@`, npm may still 404 for a few minutes while provenance is processed; recurl the version URL before republishing.
2. **GitHub:** a **latest** (non-draft, non-prerelease) Release on `karlorz/codex-host` for the same tag. The updater reads `/releases/latest` and rejects prereleases, so a prerelease-only fork Release leaves Updates empty or in error instead of offering the fork build.
-Numeric fork counters such as `0.13.3-0` and `0.13.3-0-1` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`, `-cpa.1`) stay on `next` / `test` and GitHub prerelease. `packages/repository-automation/src/release.mjs` treats a hyphen plus one or more digit groups (`0.13.3-0`, `0.13.3-0-1`) as latest, not as `next`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
+Numeric fork counters such as `0.13.3-0` and `0.13.3-1` are GitHub latest and npm `latest`. Named SemVer prereleases (`-rc.1`, `-beta.1`, `-test.1`, `-cpa.1`) stay on `next` / `test` and GitHub prerelease. `packages/repository-automation/src/release.mjs` treats a hyphen plus one or more digit groups (`0.13.3-0`, `0.13.3-1`) as latest, not as `next`. Do not `npm publish` `@codexhost/cli`. Local `npm run install:local` packs and installs tarballs without the registry.
-Shipped latest on this fork is `v0.13.2-3`: GitHub latest plus `@karlorz/codexhost-cli@0.13.2-3` and all six platform packages on npm `latest`. The next unused counter after syncing upstream `0.13.3` is `0.13.3-0`.
+Shipped latest on this fork is `v0.13.3-0`: GitHub latest plus `@karlorz/codexhost-cli@0.13.3-0` and all six platform packages on npm `latest`. The next unused counter while upstream holds `0.13.3` is `0.13.3-1`.
## GitHub Actions secrets
@@ -89,4 +89,4 @@ git fetch origin --tags --prune
git ls-remote --tags origin 'refs/tags/v0.13.3-*'
```
-Shipped tags include `v0.13.2-0`, `v0.13.2-1`, `v0.13.2-2`, and `v0.13.2-3`. After upstream `0.13.3`, the first unused fork tag is `0.13.3-0`; later ones on the same upstream are `0.13.3-0-1`, `0.13.3-0-2`. Then `npm run release:prepare -- 0.13.3-0` on `main`, push the four version files, wait for that SHA's `main` push CI, then push the annotated tag.
+Shipped tags include `v0.13.2-0` through `v0.13.2-3` and `v0.13.3-0`. After upstream `0.13.3`, later fork tags on the same upstream are `0.13.3-1`, `0.13.3-2`, …. Then `npm run release:prepare -- 0.13.3-1` on `main`, push the four version files, wait for that SHA's `main` push CI, then push the annotated tag.
From 2409457930b05933a36a1e6a321e3b30fd7e7aa9 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 07:12:04 +0900
Subject: [PATCH 19/98] chore: prepare v0.13.3-1
---
Cargo.lock | 10 +++++-----
Cargo.toml | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index b72a79083..e64d2d105 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "codexhost-gate-a-native"
-version = "0.13.3-0"
+version = "0.13.3-1"
dependencies = [
"codexhost-platform",
"codexhost-shim",
@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "codexhost-launcher"
-version = "0.13.3-0"
+version = "0.13.3-1"
dependencies = [
"codexhost-platform",
"fs2",
@@ -106,7 +106,7 @@ dependencies = [
[[package]]
name = "codexhost-platform"
-version = "0.13.3-0"
+version = "0.13.3-1"
dependencies = [
"libproc",
"nix",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "codexhost-shim"
-version = "0.13.3-0"
+version = "0.13.3-1"
dependencies = [
"codexhost-platform",
"fs2",
@@ -131,7 +131,7 @@ dependencies = [
[[package]]
name = "codexhost-updater"
-version = "0.13.3-0"
+version = "0.13.3-1"
dependencies = [
"codexhost-platform",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index a540eb25c..c5c4ca8ec 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ default-members = [
resolver = "3"
[workspace.package]
-version = "0.13.3-0"
+version = "0.13.3-1"
edition = "2024"
rust-version = "1.97.1"
publish = false
diff --git a/package-lock.json b/package-lock.json
index 139eaaf39..d7bf92728 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexhost",
- "version": "0.13.3-0",
+ "version": "0.13.3-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexhost",
- "version": "0.13.3-0",
+ "version": "0.13.3-1",
"license": "LGPL-3.0-only",
"workspaces": [
"packages/*",
diff --git a/package.json b/package.json
index 64461496b..ffb8df1f5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexhost",
- "version": "0.13.3-0",
+ "version": "0.13.3-1",
"private": true,
"license": "LGPL-3.0-only",
"type": "module",
From 5cd3f8eeddd7fee0d8ac8bc7c1f88ff7ff923764 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 19:08:25 +0900
Subject: [PATCH 20/98] feat: project Grok Imagine files as Desktop
imageGeneration
Emit Host generatedImage items from Grok ImageGen/ImageEdit ACP output and
project them to Desktop imageGeneration with savedPath so the official
gallery can render session files.
---
docs/harnesses/grok/generated-images.md | 11 ++
docs/index.md | 1 +
packages/adapters/grok/src/grok-adapter.ts | 45 ++++++--
.../adapters/grok/src/grok-file-change.ts | 2 +-
.../adapters/grok/src/grok-generated-image.ts | 101 ++++++++++++++++++
packages/adapters/grok/src/grok-history.ts | 42 ++++++--
.../adapters/grok/src/local-media-markdown.ts | 2 +-
.../adapters/grok/test/grok-adapter.test.ts | 62 +++++++++++
.../grok/test/grok-generated-image.test.ts | 79 ++++++++++++++
.../adapters/grok/test/grok-history.test.ts | 54 ++++++++++
packages/harness-adapter/src/index.ts | 1 +
packages/harness-adapter/src/testing.ts | 12 +++
packages/harness-adapter/src/text-session.ts | 10 ++
.../protocol-core/src/codex-ui-projector.ts | 12 +++
.../test/codex-ui-projector.test.ts | 62 +++++++++++
15 files changed, 476 insertions(+), 20 deletions(-)
create mode 100644 docs/harnesses/grok/generated-images.md
create mode 100644 packages/adapters/grok/src/grok-generated-image.ts
create mode 100644 packages/adapters/grok/test/grok-generated-image.test.ts
diff --git a/docs/harnesses/grok/generated-images.md b/docs/harnesses/grok/generated-images.md
new file mode 100644
index 000000000..38f5b6f9d
--- /dev/null
+++ b/docs/harnesses/grok/generated-images.md
@@ -0,0 +1,11 @@
+# Grok generated images in Codex Desktop
+
+Grok Imagine (`image_gen` / `image_edit`) writes a file under the native session `images/` directory and reports ACP `rawOutput.type` `ImageGen` or `ImageEdit` with an absolute `path`.
+
+The Grok Adapter emits a Host `generatedImage` Item with that `savedPath`. Protocol Core projects it to Desktop `imageGeneration` (`item/started` then `item/completed` with `completedAtMs`). Desktop renders the official generated-image gallery.
+
+This is separate from local-media Markdown rewrite (`` / backticks), which remains for videos and for agent text that already cites a file.
+
+Do not project Imagine files as `fileChange`. Do not send image base64 through the 64 KB tool-output budget.
+
+Live JPEG gallery display still needs a Desktop session check after this projection.
diff --git a/docs/index.md b/docs/index.md
index b9415d501..c48211488 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -90,6 +90,7 @@
| 文档 | 内容与阅读时机 |
| --- | --- |
| [`harnesses/grok/subagent-status-and-model.md`](harnesses/grok/subagent-status-and-model.md) | Grok Subagent 状态、Model、Transcript 与 Desktop 投影;修改 Grok Subagent 时阅读。 |
+| [`harnesses/grok/generated-images.md`](harnesses/grok/generated-images.md) | Grok Imagine files project to Desktop `imageGeneration`; keep Markdown rewrite for videos and cited paths. |
### ZCode
diff --git a/packages/adapters/grok/src/grok-adapter.ts b/packages/adapters/grok/src/grok-adapter.ts
index 50ceed0a8..5e9cba17c 100644
--- a/packages/adapters/grok/src/grok-adapter.ts
+++ b/packages/adapters/grok/src/grok-adapter.ts
@@ -101,6 +101,12 @@ import {
} from "./grok-subagent.js";
import type { GrokCompactResult } from "./grok-manual-compaction.js";
import { projectGrokFileChanges } from "./grok-file-change.js";
+import {
+ grokGeneratedImageItem,
+ mergeGrokGeneratedImage,
+ projectGrokGeneratedImage,
+ type GrokGeneratedImage,
+} from "./grok-generated-image.js";
import { forkGrokSession } from "./grok-fork.js";
import { mapGrokReplay } from "./grok-history.js";
import { rewindGrokLastTurn } from "./grok-rewind.js";
@@ -188,6 +194,7 @@ export interface GrokAcpTransportLike {
interface ActiveTool {
item: GrokProjectedToolItem;
status?: string;
+ generatedImage?: GrokGeneratedImage;
}
interface ActiveApproval {
@@ -1139,7 +1146,12 @@ class GrokHarnessSession implements HarnessSession {
});
const projection = projectGrokToolOutput(event.content, event.rawOutput, this.#toolOutputLimit);
if (hasGrokToolProjection(projection)) item = applyGrokToolProjection(item, projection);
- active.tools.set(event.callId, { item, ...(event.status ? { status: event.status } : {}) });
+ const generatedImage = projectGrokGeneratedImage(event.content, event.rawOutput);
+ active.tools.set(event.callId, {
+ item,
+ ...(event.status ? { status: event.status } : {}),
+ ...(generatedImage ? { generatedImage } : {}),
+ });
this.#event({ type: "item.started", turnId: active.command.turnId, item });
if (event.status === "completed" || event.status === "failed") {
this.#completeTool(active, event.callId, event.status, event.content, event.rawOutput);
@@ -1213,6 +1225,10 @@ class GrokHarnessSession implements HarnessSession {
}
}
if (event.status) tool.status = event.status;
+ tool.generatedImage = mergeGrokGeneratedImage(
+ tool.generatedImage,
+ projectGrokGeneratedImage(event.content, event.rawOutput),
+ );
if (event.status === "completed" || event.status === "failed") {
this.#completeTool(active, event.callId, event.status, event.content, event.rawOutput);
}
@@ -1247,15 +1263,24 @@ class GrokHarnessSession implements HarnessSession {
if (status !== "completed") return;
this.#completeWatchedSubagents(active, tool.item, content, rawOutput);
const changes = projectGrokFileChanges(content, this.#cwd);
- if (!changes) return;
- const fileItem: HostFileChangeItem = {
- type: "fileChange",
- itemId: hostItemIdSchema.parse(this.#randomUUID()),
- sourceItemIds: [tool.item.itemId],
- changes,
- };
- this.#event({ type: "item.started", turnId: active.command.turnId, item: fileItem });
- this.#completeItem(active, fileItem, { status: "succeeded" });
+ if (changes) {
+ const fileItem: HostFileChangeItem = {
+ type: "fileChange",
+ itemId: hostItemIdSchema.parse(this.#randomUUID()),
+ sourceItemIds: [tool.item.itemId],
+ changes,
+ };
+ this.#event({ type: "item.started", turnId: active.command.turnId, item: fileItem });
+ this.#completeItem(active, fileItem, { status: "succeeded" });
+ }
+ if (!tool.generatedImage) return;
+ const imageItem = grokGeneratedImageItem(
+ tool.generatedImage,
+ hostItemIdSchema.parse(this.#randomUUID()),
+ tool.item.itemId,
+ );
+ this.#event({ type: "item.started", turnId: active.command.turnId, item: imageItem });
+ this.#completeItem(active, imageItem, { status: "succeeded" });
}
#completeSubagentTool(
diff --git a/packages/adapters/grok/src/grok-file-change.ts b/packages/adapters/grok/src/grok-file-change.ts
index 84d090b26..5246302e4 100644
--- a/packages/adapters/grok/src/grok-file-change.ts
+++ b/packages/adapters/grok/src/grok-file-change.ts
@@ -12,7 +12,7 @@ function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
-function validAbsolutePath(value: unknown): value is string {
+export function validAbsolutePath(value: unknown): value is string {
return (
typeof value === "string" &&
path.isAbsolute(value) &&
diff --git a/packages/adapters/grok/src/grok-generated-image.ts b/packages/adapters/grok/src/grok-generated-image.ts
new file mode 100644
index 000000000..909defe8c
--- /dev/null
+++ b/packages/adapters/grok/src/grok-generated-image.ts
@@ -0,0 +1,101 @@
+import path from "node:path";
+
+import type { HostGeneratedImageItem } from "@codexhost/harness-adapter";
+import type { HostItemId } from "@codexhost/shared-contracts";
+
+import { validAbsolutePath } from "./grok-file-change.js";
+import { mediaFileExists } from "./local-media-markdown.js";
+
+const GENERATED_IMAGE_TYPES = new Set(["ImageGen", "ImageEdit"]);
+
+export interface GrokGeneratedImage {
+ savedPath: string;
+ revisedPrompt?: string;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function stringField(value: unknown, key: string): string | undefined {
+ return isRecord(value) && typeof value[key] === "string" && value[key].length > 0
+ ? value[key]
+ : undefined;
+}
+
+function fromNativeRecord(
+ value: unknown,
+ exists: (absolutePath: string) => boolean,
+): GrokGeneratedImage | null {
+ if (!isRecord(value)) return null;
+ const type = stringField(value, "type");
+ const sessionFolder = stringField(value, "session_folder");
+ const typed = type !== undefined && GENERATED_IMAGE_TYPES.has(type);
+ const imageFolder = sessionFolder === "images";
+ if (!typed && !imageFolder) return null;
+ const savedPath = stringField(value, "path");
+ if (!validAbsolutePath(savedPath)) return null;
+ const resolved = path.resolve(savedPath);
+ if (!exists(resolved)) return null;
+ const filename = stringField(value, "filename");
+ return {
+ savedPath: resolved,
+ ...(filename ? { revisedPrompt: filename } : {}),
+ };
+}
+
+function parseJsonObject(text: string): unknown {
+ const trimmed = text.trim();
+ if (!(trimmed.startsWith("{") && trimmed.endsWith("}"))) return null;
+ try {
+ return JSON.parse(trimmed) as unknown;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Read Grok Imagine ACP output. Only ImageGen/ImageEdit native records qualify;
+ * tool names and markdown links are ignored.
+ */
+export function projectGrokGeneratedImage(
+ content: unknown,
+ rawOutput: unknown,
+ exists: (absolutePath: string) => boolean = mediaFileExists,
+): GrokGeneratedImage | null {
+ const fromRaw = fromNativeRecord(rawOutput, exists);
+ if (fromRaw) return fromRaw;
+ if (!Array.isArray(content)) return null;
+ for (const entry of content) {
+ if (!isRecord(entry)) continue;
+ const nested = entry.type === "content" && isRecord(entry.content) ? entry.content : entry;
+ if (isRecord(nested) && nested.type === "text" && typeof nested.text === "string") {
+ const parsed = fromNativeRecord(parseJsonObject(nested.text), exists);
+ if (parsed) return parsed;
+ }
+ const direct = fromNativeRecord(nested, exists);
+ if (direct) return direct;
+ }
+ return null;
+}
+
+export function mergeGrokGeneratedImage(
+ previous: GrokGeneratedImage | undefined,
+ next: GrokGeneratedImage | null,
+): GrokGeneratedImage | undefined {
+ return next ?? previous;
+}
+
+export function grokGeneratedImageItem(
+ image: GrokGeneratedImage,
+ itemId: HostItemId,
+ sourceItemId: HostItemId,
+): HostGeneratedImageItem {
+ return {
+ type: "generatedImage",
+ itemId,
+ savedPath: image.savedPath,
+ sourceItemIds: [sourceItemId],
+ ...(image.revisedPrompt ? { revisedPrompt: image.revisedPrompt } : {}),
+ };
+}
diff --git a/packages/adapters/grok/src/grok-history.ts b/packages/adapters/grok/src/grok-history.ts
index 6b4596354..ed0baade3 100644
--- a/packages/adapters/grok/src/grok-history.ts
+++ b/packages/adapters/grok/src/grok-history.ts
@@ -18,6 +18,12 @@ import {
import type { GrokTransportEvent } from "./acp-transport.js";
import { projectGrokFileChanges } from "./grok-file-change.js";
+import {
+ grokGeneratedImageItem,
+ mergeGrokGeneratedImage,
+ projectGrokGeneratedImage,
+ type GrokGeneratedImage,
+} from "./grok-generated-image.js";
import { grokMediaResolveRoots, rewriteLocalMediaMarkdown } from "./local-media-markdown.js";
import {
grokNativeSubagentId,
@@ -124,6 +130,7 @@ export function mapGrokReplay(
let agent: HostAgentMessageItem | null = null;
let reasoning: HostReasoningItem | null = null;
const tools = new Map();
+ const generatedImages = new Map();
const mediaRoots = grokMediaResolveRoots(cwd, sessionDirectory);
const subagents = new Map();
const subagentAliases = new Map();
@@ -247,6 +254,7 @@ export function mapGrokReplay(
items.push({ item: tool, outcome: { status: "succeeded" } });
}
tools.clear();
+ generatedImages.clear();
for (const item of subagents.values()) {
items.push({ item, outcome: { status: "succeeded" } });
}
@@ -263,6 +271,11 @@ export function mapGrokReplay(
if (hasGrokToolProjection(projection)) {
tools.set(callId, applyGrokToolProjection(tool, projection));
}
+ const image = mergeGrokGeneratedImage(
+ generatedImages.get(callId),
+ projectGrokGeneratedImage(content, rawOutput),
+ );
+ if (image) generatedImages.set(callId, image);
};
const completeTool = (
callId: string,
@@ -274,6 +287,8 @@ export function mapGrokReplay(
const tool = tools.get(callId);
if (!tool) return;
tools.delete(callId);
+ const generatedImage = generatedImages.get(callId);
+ generatedImages.delete(callId);
const outcome: HostItemSnapshot["outcome"] =
status === "failed"
? {
@@ -296,14 +311,24 @@ export function mapGrokReplay(
items.push({ item: tool, outcome });
if (status !== "completed") return;
const changes = projectGrokFileChanges(content, cwd);
- if (!changes) return;
- const fileItem: HostFileChangeItem = {
- type: "fileChange",
- itemId: stableId("file-change", turnIndex, ++messageIndex),
- sourceItemIds: [tool.itemId],
- changes,
- };
- items.push({ item: fileItem, outcome: { status: "succeeded" } });
+ if (changes) {
+ const fileItem: HostFileChangeItem = {
+ type: "fileChange",
+ itemId: stableId("file-change", turnIndex, ++messageIndex),
+ sourceItemIds: [tool.itemId],
+ changes,
+ };
+ items.push({ item: fileItem, outcome: { status: "succeeded" } });
+ }
+ if (!generatedImage) return;
+ items.push({
+ item: grokGeneratedImageItem(
+ generatedImage,
+ stableId("generated-image", turnIndex, ++messageIndex),
+ tool.itemId,
+ ),
+ outcome: { status: "succeeded" },
+ });
};
const completeTurn = (outcome: HistoricalTurnOutcome, terminalKey?: string): void => {
if (input.length === 0) return;
@@ -358,6 +383,7 @@ export function mapGrokReplay(
agent = null;
reasoning = null;
tools.clear();
+ generatedImages.clear();
subagents.clear();
subagentAliases.clear();
continue;
diff --git a/packages/adapters/grok/src/local-media-markdown.ts b/packages/adapters/grok/src/local-media-markdown.ts
index bbbd6918b..d78eae31e 100644
--- a/packages/adapters/grok/src/local-media-markdown.ts
+++ b/packages/adapters/grok/src/local-media-markdown.ts
@@ -312,7 +312,7 @@ function hasMediaExtension(value: string): boolean {
return MEDIA_EXTENSIONS.has(path.extname(pathname).toLowerCase());
}
-function mediaFileExists(absolutePath: string): boolean {
+export function mediaFileExists(absolutePath: string): boolean {
try {
return existsSync(absolutePath) && statSync(absolutePath).isFile();
} catch {
diff --git a/packages/adapters/grok/test/grok-adapter.test.ts b/packages/adapters/grok/test/grok-adapter.test.ts
index dcc6eb6c1..a9c7f9664 100644
--- a/packages/adapters/grok/test/grok-adapter.test.ts
+++ b/packages/adapters/grok/test/grok-adapter.test.ts
@@ -994,6 +994,68 @@ describe("Grok Adapter ACP projection", () => {
await adapter.close();
});
+ it("projects ImageGen files as generatedImage items and restores them from history", async () => {
+ const imageRoot = await mkdtemp(path.join(os.tmpdir(), "codexhost-grok-image-"));
+ const imagePath = path.join(imageRoot, "1.jpg");
+ await writeFile(imagePath, "jpeg");
+ const transport = new FakeGrokTransport();
+ const { adapter, session } = await openedSession(transport);
+ const iterator = session.outputs[Symbol.asyncIterator]();
+ const turnId = hostTurnIdSchema.parse("turn-generated-image");
+ try {
+ await session.execute({
+ type: "turn.start",
+ turnId,
+ input: [{ type: "text", text: "draw" }],
+ });
+ expect((await nextEvent(iterator)).type).toBe("turn.started");
+ transport.event({
+ type: "tool.call",
+ callId: "img-1",
+ title: "image_gen",
+ name: "image_gen",
+ status: "in_progress",
+ rawInput: { prompt: "cat" },
+ });
+ expect((await nextEvent(iterator)).type).toBe("item.started");
+ transport.event({
+ type: "tool.update",
+ callId: "img-1",
+ status: "completed",
+ rawOutput: {
+ type: "ImageGen",
+ path: imagePath,
+ filename: "1.jpg",
+ session_folder: "images",
+ },
+ });
+ expect(await nextEvent(iterator)).toMatchObject({
+ type: "item.completed",
+ snapshot: { item: { type: "toolExecution" }, outcome: { status: "succeeded" } },
+ });
+ expect(await nextEvent(iterator)).toMatchObject({
+ type: "item.started",
+ item: {
+ type: "generatedImage",
+ savedPath: imagePath,
+ revisedPrompt: "1.jpg",
+ },
+ });
+ expect(await nextEvent(iterator)).toMatchObject({
+ type: "item.completed",
+ snapshot: { item: { type: "generatedImage" }, outcome: { status: "succeeded" } },
+ });
+ transport.finish();
+ expect(await nextEvent(iterator)).toMatchObject({
+ type: "turn.completed",
+ outcome: { status: "succeeded" },
+ });
+ } finally {
+ await adapter.close();
+ await rm(imageRoot, { recursive: true, force: true });
+ }
+ });
+
it("keeps failed terminal ACP Diff Content Tool-only", async () => {
const transport = new FakeGrokTransport();
const { adapter, session } = await openedSession(transport);
diff --git a/packages/adapters/grok/test/grok-generated-image.test.ts b/packages/adapters/grok/test/grok-generated-image.test.ts
new file mode 100644
index 000000000..679f9cdd7
--- /dev/null
+++ b/packages/adapters/grok/test/grok-generated-image.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+
+import { projectGrokGeneratedImage } from "../src/grok-generated-image.js";
+
+const saved = "/Users/karlchow/.grok/sessions/session-1/images/1.jpg";
+const exists = (absolutePath: string) => absolutePath === saved;
+
+describe("Grok generated image projection", () => {
+ it("reads ImageGen rawOutput with an existing absolute path", () => {
+ expect(
+ projectGrokGeneratedImage(
+ undefined,
+ {
+ type: "ImageGen",
+ path: saved,
+ filename: "1.jpg",
+ session_folder: "images",
+ },
+ exists,
+ ),
+ ).toEqual({ savedPath: saved, revisedPrompt: "1.jpg" });
+ });
+
+ it("reads ImageEdit rawOutput the same way", () => {
+ expect(
+ projectGrokGeneratedImage(
+ undefined,
+ { type: "ImageEdit", path: saved, filename: "1.jpg" },
+ exists,
+ ),
+ ).toEqual({ savedPath: saved, revisedPrompt: "1.jpg" });
+ });
+
+ it("reads ACP JSON text that uses session_folder images without a type field", () => {
+ expect(
+ projectGrokGeneratedImage(
+ [
+ {
+ type: "content",
+ content: {
+ type: "text",
+ text: JSON.stringify({
+ path: saved,
+ filename: "1.jpg",
+ session_folder: "images",
+ message: "Image generated",
+ }),
+ },
+ },
+ ],
+ undefined,
+ exists,
+ ),
+ ).toEqual({ savedPath: saved, revisedPrompt: "1.jpg" });
+ });
+
+ it("ignores missing files, relative paths, and unrelated JSON", () => {
+ expect(
+ projectGrokGeneratedImage(
+ undefined,
+ { type: "ImageGen", path: saved, filename: "1.jpg" },
+ () => false,
+ ),
+ ).toBeNull();
+ expect(
+ projectGrokGeneratedImage(
+ undefined,
+ { type: "ImageGen", path: "images/1.jpg", filename: "1.jpg" },
+ exists,
+ ),
+ ).toBeNull();
+ expect(
+ projectGrokGeneratedImage(undefined, { type: "ReadFile", path: saved }, exists),
+ ).toBeNull();
+ expect(
+ projectGrokGeneratedImage([{ type: "diff", path: saved }], undefined, exists),
+ ).toBeNull();
+ });
+});
diff --git a/packages/adapters/grok/test/grok-history.test.ts b/packages/adapters/grok/test/grok-history.test.ts
index a3230ebe8..f814161ef 100644
--- a/packages/adapters/grok/test/grok-history.test.ts
+++ b/packages/adapters/grok/test/grok-history.test.ts
@@ -397,3 +397,57 @@ describe("Grok history local media Markdown", () => {
}
});
});
+
+describe("Grok history generated images", () => {
+ it("restores ImageGen files as generatedImage items", async () => {
+ const sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "codexhost-grok-image-"));
+ const imagePath = path.join(sessionDirectory, "images", "1.jpg");
+ try {
+ await mkdir(path.dirname(imagePath), { recursive: true });
+ await writeFile(imagePath, "jpeg");
+ const snapshot = mapGrokReplay(
+ [
+ { type: "user.text", text: "draw a cat" },
+ {
+ type: "tool.call",
+ callId: "img-1",
+ title: "image_gen",
+ name: "image_gen",
+ rawInput: { prompt: "cat" },
+ },
+ {
+ type: "tool.update",
+ callId: "img-1",
+ status: "completed",
+ rawOutput: {
+ type: "ImageGen",
+ path: imagePath,
+ filename: "1.jpg",
+ session_folder: "images",
+ },
+ },
+ { type: "turn.completed", nativeTurnKey: "prompt-1", stopReason: "end_turn" },
+ ],
+ grokHarnessId,
+ "session-1",
+ sessionDirectory,
+ );
+ expect(snapshot.turns[0]?.items).toMatchObject([
+ {
+ item: { type: "toolExecution", toolName: "image_gen" },
+ outcome: { status: "succeeded" },
+ },
+ {
+ item: {
+ type: "generatedImage",
+ savedPath: imagePath,
+ revisedPrompt: "1.jpg",
+ },
+ outcome: { status: "succeeded" },
+ },
+ ]);
+ } finally {
+ await rm(sessionDirectory, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/harness-adapter/src/index.ts b/packages/harness-adapter/src/index.ts
index 497963b39..90cd21600 100644
--- a/packages/harness-adapter/src/index.ts
+++ b/packages/harness-adapter/src/index.ts
@@ -61,6 +61,7 @@ export type {
HostEvent,
HostFileChange,
HostFileChangeItem,
+ HostGeneratedImageItem,
HistoricalTurnOutcome,
HostInteraction,
HostInteractionResponse,
diff --git a/packages/harness-adapter/src/testing.ts b/packages/harness-adapter/src/testing.ts
index 140954d29..2170761f2 100644
--- a/packages/harness-adapter/src/testing.ts
+++ b/packages/harness-adapter/src/testing.ts
@@ -606,6 +606,18 @@ export class FakeHarnessSession implements HarnessSession {
return itemId;
}
+ emitGeneratedImage(savedPath: string, revisedPrompt?: string): HostItemId {
+ const itemId = this.#nextItemId();
+ this.#startItem({
+ type: "generatedImage",
+ itemId,
+ savedPath,
+ ...(revisedPrompt ? { revisedPrompt } : {}),
+ });
+ this.completeItem(itemId, { status: "succeeded" });
+ return itemId;
+ }
+
askQuestion(
question: HostQuestion,
options: { itemId?: HostItemId; title?: string; expiresAt?: string } = {},
diff --git a/packages/harness-adapter/src/text-session.ts b/packages/harness-adapter/src/text-session.ts
index e564accce..218e4ff9f 100644
--- a/packages/harness-adapter/src/text-session.ts
+++ b/packages/harness-adapter/src/text-session.ts
@@ -354,6 +354,15 @@ export interface HostFileChangeItem {
sourceItemIds?: HostItemId[];
}
+/** Desktop `imageGeneration` gallery. Requires an absolute saved file. */
+export interface HostGeneratedImageItem {
+ type: "generatedImage";
+ itemId: HostItemId;
+ savedPath: string;
+ revisedPrompt?: string;
+ sourceItemIds?: HostItemId[];
+}
+
export type HostSubagentStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
export interface HostSubagentState {
@@ -385,6 +394,7 @@ export type HostItem =
| HostCommandExecutionItem
| HostToolExecutionItem
| HostFileChangeItem
+ | HostGeneratedImageItem
| HostSubagentDelegationItem;
export type HostItemUpdate =
diff --git a/packages/protocol-core/src/codex-ui-projector.ts b/packages/protocol-core/src/codex-ui-projector.ts
index f4d1d4de8..c719f5783 100644
--- a/packages/protocol-core/src/codex-ui-projector.ts
+++ b/packages/protocol-core/src/codex-ui-projector.ts
@@ -545,6 +545,18 @@ function projectItem(
changes: projectFileChanges(item.changes),
status: itemStatus(outcome),
};
+ case "generatedImage":
+ return {
+ id: item.itemId,
+ type: "imageGeneration",
+ status: itemStatus(outcome),
+ savedPath: item.savedPath,
+ result: "",
+ revisedPrompt: item.revisedPrompt ?? null,
+ inputImages: [],
+ failure: null,
+ transparentBackground: null,
+ };
case "subagentDelegation": {
const primary = item.subagents[0];
// Codex exposes one configuration per Item, not per receiver. Do not
diff --git a/packages/protocol-core/test/codex-ui-projector.test.ts b/packages/protocol-core/test/codex-ui-projector.test.ts
index 1122f51ed..e18c45317 100644
--- a/packages/protocol-core/test/codex-ui-projector.test.ts
+++ b/packages/protocol-core/test/codex-ui-projector.test.ts
@@ -1693,6 +1693,68 @@ describe("Codex UI projector", () => {
).toMatchObject({ status: "completed", items: [] });
});
+ it("projects generatedImage as Desktop imageGeneration with completedAtMs", () => {
+ const value = projector();
+ const imageId = itemId("image-1");
+ const savedPath = "/Users/karlchow/.grok/sessions/session-1/images/1.jpg";
+ value.project({ type: "turn.started", turnId });
+ expect(
+ value.project({
+ type: "item.started",
+ turnId,
+ item: {
+ type: "generatedImage",
+ itemId: imageId,
+ savedPath,
+ revisedPrompt: "1.jpg",
+ },
+ }).messages,
+ ).toMatchObject([
+ {
+ method: "item/started",
+ params: {
+ item: {
+ id: "image-1",
+ type: "imageGeneration",
+ status: "inProgress",
+ savedPath,
+ result: "",
+ revisedPrompt: "1.jpg",
+ },
+ },
+ },
+ ]);
+ expect(
+ value.project({
+ type: "item.completed",
+ turnId,
+ snapshot: {
+ item: {
+ type: "generatedImage",
+ itemId: imageId,
+ savedPath,
+ revisedPrompt: "1.jpg",
+ },
+ outcome: { status: "succeeded" },
+ },
+ }).messages,
+ ).toMatchObject([
+ {
+ method: "item/completed",
+ params: {
+ completedAtMs: expect.any(Number),
+ item: {
+ id: "image-1",
+ type: "imageGeneration",
+ status: "completed",
+ savedPath,
+ result: "",
+ },
+ },
+ },
+ ]);
+ });
+
it("projects failed Turns through the complete Codex error notification shape", () => {
const value = projector();
const message = '503: {"message":"Service temporarily unavailable","type":"api_error"}';
From 2f3467fa30b6416c76d6d1c8e421b9a15c2f824e Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 19:49:33 +0900
Subject: [PATCH 21/98] fix: assign Grok generatedImage only when present
Keep exactOptionalPropertyTypes: do not write undefined onto the optional field.
---
packages/adapters/grok/src/grok-adapter.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/adapters/grok/src/grok-adapter.ts b/packages/adapters/grok/src/grok-adapter.ts
index 5e9cba17c..a9371ce30 100644
--- a/packages/adapters/grok/src/grok-adapter.ts
+++ b/packages/adapters/grok/src/grok-adapter.ts
@@ -1225,10 +1225,11 @@ class GrokHarnessSession implements HarnessSession {
}
}
if (event.status) tool.status = event.status;
- tool.generatedImage = mergeGrokGeneratedImage(
+ const generatedImage = mergeGrokGeneratedImage(
tool.generatedImage,
projectGrokGeneratedImage(event.content, event.rawOutput),
);
+ if (generatedImage) tool.generatedImage = generatedImage;
if (event.status === "completed" || event.status === "failed") {
this.#completeTool(active, event.callId, event.status, event.content, event.rawOutput);
}
From 8dcc59c51f659bc24bd5026b36cb7f4c787348f4 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 19:49:34 +0900
Subject: [PATCH 22/98] docs: recover ChatGPT snag without losing session state
Record Try again recovery and the Desktop 26.1007 image-completion notification contract.
---
...odex-desktop-upgrade-diagnosis-playbook.md | 96 +++++++++++++++++++
1 file changed, 96 insertions(+)
diff --git a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
index 30ade5e67..d32088938 100644
--- a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
+++ b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
@@ -12,6 +12,102 @@
- `docs/archive/codex-desktop-incidents/26.814-compatibility-debt.md`
- `docs/archive/codex-desktop-incidents/26.908-request-manager-wrapper.md`(Fiber hook 把 Request Manager 包进 `{ hostId, manager, status }` 后,连接检查全部失败)
+## Recovering “ChatGPT hit a snag” without losing session state
+
+This screen can be a React render failure while Desktop, Host, and the native
+Harness sessions continue running. It does not establish that a session was
+lost, that an active Turn stopped, or that the Agent named in another sidebar
+Thread caused the failure. An Adapter reporting `ready / request-bridge` also
+does not establish that the conversation rendered successfully.
+
+### Recovery procedure
+
+1. Preserve the running processes and active work. Record the Desktop version,
+ build, visible error, and first relevant render stack before making changes.
+ Discover the current CDP endpoint from process arguments; do not assume a
+ fixed port. Inspect the primary conversation Renderer, not the pet overlay.
+2. Confirm that the error boundary and its **Try again** button are visible.
+ A matching string or hidden button in the DOM is insufficient: retained
+ views and rendered messages can contain the same text. Click **Try again**
+ once. This is the error-boundary retry, not a Composer submission or Desktop
+ update. Do not install an automatic reload or retry loop.
+3. If Desktop returns to project home, reopen the existing Thread from the
+ sidebar. Verify that history and a visible Composer return and that the error
+ boundary is gone. For an external Thread, verify that its existing Agent
+ ownership remains locked and that its Model control is available. Do not
+ submit a test prompt as part of recovery.
+4. Check the existing Turn's state before sending anything. Recovered history
+ and controls establish view recovery; they do not prove that an interrupted
+ response resumed or that a new Turn can complete. Mapping files and native
+ session files establish persistence, not history completeness.
+5. If one retry immediately fails again, stop retrying and capture the new
+ stack. Separate a repeatable render/data failure from a disconnected or dead
+ native backend. A controlled Desktop/Host restart is a later option when
+ view retry cannot recover or a process/connection failure requires it, after
+ checking active Turns and preserving Harness state. Restart only the
+ necessary component. An unresponsive UI is not permission to cancel work.
+
+Do not clear histories, delete mappings or native sessions, rewrite transcripts,
+replay messages, switch Harness ownership, or update Desktop as a default
+recovery step. If there is uncertainty about whether a request was accepted,
+inspect the existing Thread before explicitly retrying that request.
+
+### Image completion state failure in Desktop 26.1007.30912
+
+For build `20156` (Chromium `155.0.8059.40`), the observed render stack was
+`b0n` in `app-shared-8bd9d0399038.js:529:120057`, then `hQe` and `Qft` in
+`app-primary-6f60e9ac4103.js`. These names and coordinates identify this build
+only; they are not binding contracts.
+
+The null value in that comparator is the account's dismissed image-completion
+record, not a Thread returned by Grok. The relevant installed function is:
+
+```js
+function sameImageCompletion(latest, dismissed) {
+ return (
+ dismissed?.completedAtMs === latest.completedAtMs &&
+ dismissed.threadId === latest.threadId &&
+ dismissed.turnId === latest.turnId
+ );
+}
+```
+
+The dismissed record defaults to null. With a missing `latest.completedAtMs`,
+the first comparison is `undefined === undefined`, so evaluation continues to
+`dismissed.threadId` and throws. With a numeric completion timestamp, the first
+comparison returns false and short-circuits safely. The Composer's image limit
+hook calls this comparison whenever the latest image record exists, including
+successful completions with no usage-limit failure.
+
+The image notification listener copies `params.completedAtMs` into that account
+record. The installed native `ItemCompletedNotification` contract requires a
+Unix timestamp in milliseconds. An experimental image-preview diagnostic sent
+an `item/completed` notification without this field before the reported failure.
+That malformed notification is a concrete likely trigger: evaluating the exact
+installed comparator with the resulting value shape reproduces the reported
+exception. The account-state values at the moment of the original failure were
+not captured, so the historical causal link remains inferred.
+
+Production codexhost has no hosted-image completion synthesis on this path.
+Its external Item projector supplies `completedAtMs` on completion, and native
+notifications pass through the routing bridge. The experimental preview code
+was removed. Do not add blanket null suppression, fabricated image completions,
+or native account-state writes to compensate for this diagnostic error. Image
+presentation investigations should use the native image-tool path or inspect
+recovered image files outside Desktop's notification stream. Any future
+compatibility producer must validate the full lifecycle notification contract,
+including timing, and test downstream consumers in an isolated fixture before
+touching a user's running Renderer.
+
+The successful recovery used **Try again**, then reopened an existing Thread.
+The user subsequently confirmed that the original native conversation rendered
+again. No restart, session deletion, transcript rewriting, or message replay was
+required. A fresh Grok response was not tested. The sandbox `localStorage`
+`SecurityError` and usage-reader warning `Codex rollout changed while reading;
+refresh to retry` were also observed, but neither is established as the cause.
+The latter comes from usage statistics rejecting a file that changed during a
+read; it does not itself report session loss.
+
## 一、先建立分层模型
### 历史原生会话不能拖入引用或通过 `@` 搜索会话
From ade22207e03ae060301d7e96c9fec7467d2299d3 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 19:55:07 +0900
Subject: [PATCH 23/98] chore: prepare v0.13.3-2
---
Cargo.lock | 10 +++++-----
Cargo.toml | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index e64d2d105..d06579422 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "codexhost-gate-a-native"
-version = "0.13.3-1"
+version = "0.13.3-2"
dependencies = [
"codexhost-platform",
"codexhost-shim",
@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "codexhost-launcher"
-version = "0.13.3-1"
+version = "0.13.3-2"
dependencies = [
"codexhost-platform",
"fs2",
@@ -106,7 +106,7 @@ dependencies = [
[[package]]
name = "codexhost-platform"
-version = "0.13.3-1"
+version = "0.13.3-2"
dependencies = [
"libproc",
"nix",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "codexhost-shim"
-version = "0.13.3-1"
+version = "0.13.3-2"
dependencies = [
"codexhost-platform",
"fs2",
@@ -131,7 +131,7 @@ dependencies = [
[[package]]
name = "codexhost-updater"
-version = "0.13.3-1"
+version = "0.13.3-2"
dependencies = [
"codexhost-platform",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index c5c4ca8ec..10d8eb984 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ default-members = [
resolver = "3"
[workspace.package]
-version = "0.13.3-1"
+version = "0.13.3-2"
edition = "2024"
rust-version = "1.97.1"
publish = false
diff --git a/package-lock.json b/package-lock.json
index d7bf92728..12e671dd9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexhost",
- "version": "0.13.3-1",
+ "version": "0.13.3-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexhost",
- "version": "0.13.3-1",
+ "version": "0.13.3-2",
"license": "LGPL-3.0-only",
"workspaces": [
"packages/*",
diff --git a/package.json b/package.json
index ffb8df1f5..93ec04e8b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexhost",
- "version": "0.13.3-1",
+ "version": "0.13.3-2",
"private": true,
"license": "LGPL-3.0-only",
"type": "module",
From 228ecc87605e64dbcc239d2eb6fce2fc23d57482 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:19:44 +0900
Subject: [PATCH 24/98] feat: restore hide Codex usage warning and send gate
Re-apply the karlorz Settings toggle dropped during the upstream
v0.13.3 usage-gate sync. Hide the out-of-usage card on every Composer
by default, and lift the Renderer usage gate so Send and Enter still
work on new drafts, existing Threads, and Codex Threads.
---
.../codexhost-update-impact-audit/SKILL.md | 8 +-
docs/index.md | 2 +-
...odex-desktop-upgrade-diagnosis-playbook.md | 4 +-
docs/operations/codexhost-console.md | 2 +-
docs/product/codex-accounts.md | 2 +-
.../src/renderer-binding-probe.ts | 26 +++-
.../renderer-codex-usage-banner-preference.ts | 22 +++
.../src/renderer-codex-usage-gate.ts | 23 +--
.../src/renderer-composer-dom.ts | 51 +++++-
.../src/settings/appearance-page.ts | 32 ++++
.../src/settings/localization.ts | 20 +++
.../test/renderer-binding-probe.test.ts | 146 ++++++++++++++++++
...erer-codex-usage-banner-preference.test.ts | 56 +++++++
.../test/settings/localization.test.ts | 4 +
tools/codex-desktop-contract-audit/README.md | 4 +-
15 files changed, 377 insertions(+), 25 deletions(-)
create mode 100644 packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
create mode 100644 packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
diff --git a/.agents/skills/codexhost-update-impact-audit/SKILL.md b/.agents/skills/codexhost-update-impact-audit/SKILL.md
index 8ac006b90..58c1fb3f1 100644
--- a/.agents/skills/codexhost-update-impact-audit/SKILL.md
+++ b/.agents/skills/codexhost-update-impact-audit/SKILL.md
@@ -89,7 +89,7 @@ Read the current call sites and follow each surface independently from discovery
- sidebar decoration;
- settings entry;
- Fork;
-- Codex usage gate (`renderer-codex-usage-gate.ts`, driven by `renderComposerAgentControl`'s external-submission readiness);
+- Codex usage gate (`renderer-codex-usage-gate.ts`, driven by `renderComposerAgentControl`'s usage-gate allowance for Codex or a ready external Adapter);
- Host create and subsequent-Turn routing.
Prefer unique semantic candidates plus ownership checks. Record fail-closed behavior for absent or ambiguous candidates. Keep source relocation separate from an actual anchor or ownership change.
@@ -128,7 +128,7 @@ The runner does not accept the `.app` directory. A controlled flow may verify Ag
### Codex usage gate probe
-This surface lets an external Harness submit while the ChatGPT-signed-in Codex subscription is out of usage by projecting `false` for one Composer's two Desktop usage-gate subscriptions. It depends on private React/state-library internals, so check it on every update. `npm run audit:codex-desktop` reports its structure as the `codex-usage-gate` surface (see `tools/codex-desktop-contract-audit/README.md`); `codex-usage-account-gate-dormant` means the audit ran without a ChatGPT sign-in whose Account gate reads usage. The audit does not verify behavior.
+This surface lets a Composer submit while the ChatGPT-signed-in Codex subscription is out of usage by projecting `false` for one Composer's two Desktop usage-gate subscriptions. It depends on private React/state-library internals, so check it on every update. `npm run audit:codex-desktop` reports its structure as the `codex-usage-gate` surface (see `tools/codex-desktop-contract-audit/README.md`); `codex-usage-account-gate-dormant` means the audit ran without a ChatGPT sign-in whose Account gate reads usage. The audit does not verify behavior.
Bundle evidence (evidence only, not contracts): the Composer owner combines `submitDisabled` from an Account rate-limit selector (`authMethod !== "chatgpt"` early return, then `rate_limit.allowed === false`, gated to host `local`) and a reserve `hardBlocked` selector. Confirm both still exist and still feed `submitDisabled`, and that no new Account-wide usage blocker was added beside them. If Desktop now exempts external models/Threads or stops blocking in the Renderer, report that the module can be removed.
@@ -141,7 +141,7 @@ Manual read-only live probe, when the audit surface is not `no-impact` or its co
With codexhost running and an external Agent selected, bound gates show an instance `getSnapshot` of `() => false` for both hooks. The Agent control's hover title shows the "cannot separate from Codex usage" message when binding failed.
-Behavioral check requires a ChatGPT account that is out of Codex usage; API-key sign-in never triggers the gate. With non-empty text and an external Agent, Send is enabled in a new draft and an existing Thread; after switching to Codex it returns to disabled. Do not submit a message unless the user asks. Remove any temporary text without touching text the user typed. Report `unverified` when no exhausted account is available.
+Behavioral check requires a ChatGPT account that is out of Codex usage; API-key sign-in never triggers the gate. With non-empty text, Send and Enter are enabled in a new draft, an existing Thread, and a Codex Thread. Do not submit a message unless the user asks. Remove any temporary text without touching text the user typed. Report `unverified` when no exhausted account is available.
Completion criterion: no surface receives a live verdict from bundle inspection alone.
@@ -205,5 +205,5 @@ Do not bury the answer in the evidence. “Frontend changed” and “codexhost
- Declaring Host routing, title creation, or Fork healthy without exercising that boundary.
- Updating multiple controls before isolating the failed ownership contract.
- Reformatting, resetting, staging, or overwriting unrelated work while investigating.
-- Declaring the Codex usage gate healthy from a Codex-selected, empty, API-key-signed-in, or not-exhausted Composer; only the external Agent path with exhausted ChatGPT usage exercises it.
+- Declaring the Codex usage gate healthy from an empty, API-key-signed-in, or not-exhausted Composer; exhausted ChatGPT usage on a new draft, existing Thread, or Codex Thread exercises it.
- Checking only the Send button's DOM `disabled`; Desktop also guards Enter and the submit function with the same `submitDisabled`.
diff --git a/docs/index.md b/docs/index.md
index c48211488..dd3a492ad 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -102,7 +102,7 @@
| 文档 | 内容与阅读时机 |
| --- | --- |
-| [`product/codex-accounts.md`](product/codex-accounts.md) | 设置页中的原生账号与额度布局、外部 Harness 不受 Codex 额度门限制、手动导入 Pi 及导入记录管理;修改账号 UI、发送额度门、查询或凭证导入链路时阅读。 |
+| [`product/codex-accounts.md`](product/codex-accounts.md) | 设置页中的原生账号与额度布局、外部 Harness 不受 Codex 额度门限制、外观页「隐藏 Codex 额度警告」(默认开启)、手动导入 Pi 及导入记录管理;修改账号 UI、发送额度门、额度横幅或凭证导入链路时阅读。 |
| [`product/usage-metering.md`](product/usage-metering.md) | 外部 Harness 与官方 Codex 的会话用量、插件可选能力、实时刷新、费用、缓存命中、首字延迟及速度限制;修改计量、价格表或 `pricing.json` 时阅读。 |
| [`product/usage-statistics.md`](product/usage-statistics.md) | 控制台与 Desktop 内置设置的全局用量统计:独立 Codex 统计插件、各 Harness 原生会话去重、按模型 ID 读时计价与缓存;新增读取器或修改统计口径时阅读。 |
| [`product/codex-native-account-switching-design.md`](product/codex-native-account-switching-design.md) | 移除 Codex 多账号切换后的只读边界;修改 Codex 认证或账号路由时阅读。 |
diff --git a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
index d32088938..6a1399456 100644
--- a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
+++ b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
@@ -424,7 +424,7 @@ grok
### 检查 Codex 额度门
-ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 disabled,且 Agent 控件悬停提示「无法将此 Harness 与 Codex 额度限制分离」,说明 `renderer-codex-usage-gate.ts` 没有绑定成功。API Key 登录不会触发这道门,不能用来复现。
+ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 disabled,且 Agent 控件悬停提示「无法将此 Harness 与 Codex 额度限制分离」,说明 `renderer-codex-usage-gate.ts` 没有绑定成功。API Key 登录不会触发这道门,不能用来复现。额度耗尽横幅是否显示由设置 → 外观「隐藏 Codex 额度警告」控制(默认开启,作用于所有 Composer,包括 Codex 会话);横幅隐藏成功不能代替这道发送门的绑定检查。
在主 Renderer 中只读检查(不修改 getSnapshot,不订阅 store):
@@ -550,7 +550,7 @@ ChatGPT 登录的 Codex 额度耗尽时,外部 Agent 的发送按钮仍为 dis
- [ ] 点击 DeepSeek Harness 后 Model catalog 可加载
- [ ] 点击 Grok 后 Model catalog 可加载
- [ ] 切回 Codex 后原生 Composer 仍可用
-- [ ] ChatGPT 账号 Codex 额度耗尽时,新 Thread 和已有外部 Thread 均可发送;切回 Codex 后发送按钮恢复 disabled
+- [ ] ChatGPT 账号 Codex 额度耗尽时,新 Thread、已有外部 Thread 和 Codex Thread 的发送按钮均可点击(空输入等其他原生限制仍生效)
- [ ] 新 Thread 和未锁定 Thread 都完成切换验证
- [ ] 已锁定 Thread 仍按设计保持 locked
- [ ] 真实错误不会只被转换为笼统的 `Models unavailable`
diff --git a/docs/operations/codexhost-console.md b/docs/operations/codexhost-console.md
index 50b1df2ff..80177803d 100644
--- a/docs/operations/codexhost-console.md
+++ b/docs/operations/codexhost-console.md
@@ -6,7 +6,7 @@
- **设置**:连接、远程连接、账号、会话导入、更新,与 Codex 设置页使用同一套页面和 Host 接口。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。
- **其他**:关于。
-外观(思考文本显示、空闲释放、改写实时语音)由 Codex 界面保存,只能在 Codex 设置页修改。本 fork 的改写实时语音默认开启;`~/.codex/config.toml` 里的 realtime 基址仍需自行配置。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
+外观(思考文本显示、隐藏 Codex 额度警告、空闲释放、改写实时语音)由 Codex 界面保存,只能在 Codex 设置页修改。隐藏 Codex 额度警告与改写实时语音均默认开启。`~/.codex/config.toml` 里的 realtime 基址仍需自行配置。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
## 项目公告
diff --git a/docs/product/codex-accounts.md b/docs/product/codex-accounts.md
index 2435727a1..cdc7af973 100644
--- a/docs/product/codex-accounts.md
+++ b/docs/product/codex-accounts.md
@@ -24,7 +24,7 @@
ChatGPT 登录的 Codex 订阅额度耗尽时,Desktop 在 Renderer 中用两道账号级布尔门禁用 Composer 提交:账号额度门和 reserve `hardBlocked`。API Key 登录不经过这两道门。它们是界面上的订阅额度预检,不是协议限制;外部 Harness 的 `turn/start` 由 Host 路由,不会发到官方后端。
-因此在单个 Composer 选中外部 Agent、Adapter 就绪且没有 codexhost 自身的提交阻塞时,`renderer-codex-usage-gate.ts` 只把该 Composer 对这两道门的订阅快照投影为 `false`,继续走原生提交链路。不写账号、atom 或额度查询缓存;旁路生效期间只隐藏该 Composer 内的 Codex 额度横幅,恢复原生状态时还原其显示样式。其他 Composer、Codex 路径和空输入、附件、运行中等其他原生限制不受影响。外部 Harness 的真实额度与错误由其自身处理。切回 Codex、Composer 移除或扩展卸载时恢复实时原生结果。
+因此在单个 Composer 没有 codexhost 自身的提交阻塞时(Codex,或外部 Agent 且 Adapter 就绪),`renderer-codex-usage-gate.ts` 只把该 Composer 对这两道门的订阅快照投影为 `false`,继续走原生提交链路。不写账号、atom 或额度查询缓存。设置 → 外观中的「隐藏 Codex 额度警告」默认开启:所有 Composer 上的官方额度耗尽横幅(Composer 内唯一带标题与 CTA 按钮的 `aside`,含 Reset usage / Upgrade;部分 Desktop 版本另带 `role="status"` 与 `aria-live="polite"`)都隐藏,包括 Codex 会话;关闭该开关后横幅恢复原生显示。空输入、附件、运行中等其他原生限制不受影响。官方 Codex 后端仍可能因额度拒绝该 Turn;外部 Harness 的真实额度与错误由其自身处理。Composer 移除或扩展卸载时恢复实时原生结果与横幅。
门按其 selector 实际读取的字段识别,不依赖压缩名或 hook 序号。兼容直接 `[store, atom]` 订阅及 Desktop 26.928 的 readonly signal adapter / lazy snapshot wrapper:以成对的订阅 effect 和原生快照一致性验证 wrapper,限量重放 readonly 布尔 selector 的依赖以识别间接 reserve 门;拒绝循环依赖、追踪型 render、混合门和快照不匹配。Desktop 26.1002 的 Account 门可读取 reserve `active` 作为豁免,并同时由 submit owner 和编辑器到 owner 之间的子组件订阅;这些订阅按组件分别投影。每次重试从 React 当前已提交树重新发现作用域,包括 alternate 切换后才挂载的子组件;无法确认当前作用域时恢复原生限制。释放时分别恢复 subscriber 与 React instance 原来的 getter,不把 wrapper 替换成另一个函数。无法唯一识别时保留原生限制,并在 Agent 控件悬停提示中说明。升级后的诊断步骤见 [Desktop 更新兼容性诊断手册](../operations/codex-desktop-upgrade-diagnosis-playbook.md#检查-codex-额度门)。
diff --git a/packages/renderer-extension/src/renderer-binding-probe.ts b/packages/renderer-extension/src/renderer-binding-probe.ts
index 7e311c412..cce50d084 100644
--- a/packages/renderer-extension/src/renderer-binding-probe.ts
+++ b/packages/renderer-extension/src/renderer-binding-probe.ts
@@ -58,6 +58,10 @@ import {
TRANSCRIPT_ITEM_SELECTOR,
} from "./renderer-transcript-dom.js";
import { RendererCodexAccountState } from "./renderer-codex-account-state.js";
+import {
+ CODEX_USAGE_BANNER_CHANGE_EVENT,
+ CODEX_USAGE_BANNER_STORAGE_KEY,
+} from "./renderer-codex-usage-banner-preference.js";
import {
createRendererCodexUsageGate,
type RendererCodexUsageGate,
@@ -1009,7 +1013,7 @@ export function installRendererBindingProbe(
const currentCodexAccount = accounts?.accounts.find(
({ accountId }) => accountId === accounts.readyAccountId,
);
- const externalSubmissionReady = renderComposerAgentControl(
+ const usageGateAllowed = renderComposerAgentControl(
mounted.control,
controller.get(mounted.composer),
composerAdapterState(mounted),
@@ -1023,7 +1027,7 @@ export function installRendererBindingProbe(
currentCodexAccount ?? null,
mounted.ownershipStatus === "error",
);
- showCodexUsageGateStatus(mounted, mounted.codexUsageGate.update(externalSubmissionReady));
+ showCodexUsageGateStatus(mounted, mounted.codexUsageGate.update(usageGateAllowed));
if (mounted.control.usage) {
mounted.control.usage.onOpen = () => {
void refreshThreadUsage(
@@ -3122,10 +3126,26 @@ export function installRendererBindingProbe(
}
}
};
+ const onUsageBannerPreference = (): void => {
+ for (const mounted of mountedByComposer.values()) {
+ const hideCodexControls =
+ controller.isSwitching(mounted.composer) ||
+ controller.get(mounted.composer).agent !== "codex";
+ reconcileComposerNativeControls(mounted.control, hideCodexControls, hideCodexControls);
+ mounted.codexUsageGate.refresh();
+ }
+ };
+ const onUsageBannerStorage = (event: StorageEvent): void => {
+ if (event.key === CODEX_USAGE_BANNER_STORAGE_KEY || event.key === null) {
+ onUsageBannerPreference();
+ }
+ };
window.addEventListener("codexhost:draft-prewarm-policy-changed", onHostRouteChange);
window.addEventListener("codexhost:draft-workspace", onDraftWorkspace);
window.addEventListener("codexhost:renderer-adapter-status", onAdapterStatus);
window.addEventListener("focus", onWindowFocus);
+ window.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, onUsageBannerPreference);
+ window.addEventListener("storage", onUsageBannerStorage);
delegationMention = installRendererDelegationMention(document, {
onOpen: (editor) => {
const composer = composerForElement(editor);
@@ -3319,6 +3339,8 @@ export function installRendererBindingProbe(
window.removeEventListener("codexhost:draft-workspace", onDraftWorkspace);
window.removeEventListener("codexhost:renderer-adapter-status", onAdapterStatus);
window.removeEventListener("focus", onWindowFocus);
+ window.removeEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, onUsageBannerPreference);
+ window.removeEventListener("storage", onUsageBannerStorage);
for (const state of harnessAvailabilityByHost.values()) {
state.requestGeneration += 1;
state.codexAccounts?.dispose();
diff --git a/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts b/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
new file mode 100644
index 000000000..340770707
--- /dev/null
+++ b/packages/renderer-extension/src/renderer-codex-usage-banner-preference.ts
@@ -0,0 +1,22 @@
+export const CODEX_USAGE_BANNER_STORAGE_KEY = "codexhost.codex-usage-banner-hidden.v1";
+export const CODEX_USAGE_BANNER_CHANGE_EVENT = "codexhost:codex-usage-banner-changed";
+
+/** Hide the native Codex out-of-usage card on every Composer. Default on. */
+export function readCodexUsageBannerPreference(owner: Window | null | undefined): boolean {
+ if (!owner) return true;
+ try {
+ return owner.localStorage.getItem(CODEX_USAGE_BANNER_STORAGE_KEY) !== "0";
+ } catch {
+ return true;
+ }
+}
+
+export function writeCodexUsageBannerPreference(owner: Window, enabled: boolean): boolean {
+ try {
+ owner.localStorage.setItem(CODEX_USAGE_BANNER_STORAGE_KEY, enabled ? "1" : "0");
+ } catch {
+ return false;
+ }
+ owner.dispatchEvent(new Event(CODEX_USAGE_BANNER_CHANGE_EVENT));
+ return true;
+}
diff --git a/packages/renderer-extension/src/renderer-codex-usage-gate.ts b/packages/renderer-extension/src/renderer-codex-usage-gate.ts
index 4ecdb79fc..c64c914a9 100644
--- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts
+++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts
@@ -1,14 +1,14 @@
/**
- * Lets an external Harness Composer submit while the signed-in ChatGPT Codex
- * subscription is out of usage.
+ * Lets a Composer submit while the signed-in ChatGPT Codex subscription is
+ * out of usage.
*
* Desktop blocks Composer submission through two boolean selectors: the
* ChatGPT Account rate-limit gate and the reserve `hardBlocked` gate. Both are
* Account-wide, only apply to ChatGPT sign-in, and are not a protocol limit.
* This module projects `false` for those two subscriptions of one Composer
- * only. It never writes Accounts, atoms or query data, so the Codex path and
- * other Composers keep the native result. Other native submit blockers are
- * unaffected. Anything that cannot be verified keeps the native restriction.
+ * only. It never writes Accounts, atoms or query data, so other Composers keep
+ * the native result. Other native submit blockers are unaffected. Anything
+ * that cannot be verified keeps the native restriction.
*
* Desktop 26.1002 moved the Account gate into a child of the submit owner and
* lets an active reserve exempt it, so gates are collected along the Composer's
@@ -17,6 +17,7 @@
import { committedReactAncestors } from "@codexhost/desktop-control/renderer-bindings";
import { createRendererCodexUsageBanner } from "./renderer-codex-usage-banner.js";
+import { readCodexUsageBannerPreference } from "./renderer-codex-usage-banner-preference.js";
type RecordValue = Record;
type Atom = { read(get: (atom: Atom) => unknown): unknown };
@@ -408,7 +409,7 @@ function isBound(projections: Iterable): boolean {
export type RendererCodexUsageGateStatus = "native" | "bypassed" | "unsupported";
export interface RendererCodexUsageGate {
- /** `allowed`: this Composer submits to a ready external Harness. */
+ /** `allowed`: this Composer should lift the native Codex usage submit gate. */
update(allowed: boolean): RendererCodexUsageGateStatus;
/** Re-apply the last decision, rebinding if Desktop replaced the Composer hooks. */
refresh(): RendererCodexUsageGateStatus;
@@ -448,11 +449,15 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs
}
return "bypassed";
};
- // The usage banner only describes the Codex path; hide it while bypassed.
+ // Fiber banner hide follows Settings → Appearance. Default on.
const apply = (): RendererCodexUsageGateStatus => {
const status = applyGate();
- if (status === "bypassed" && owner) banner.hide(owner.owner);
- else banner.restore();
+ const ownerWindow =
+ composer.ownerDocument?.defaultView ??
+ (typeof document !== "undefined" ? document.defaultView : null);
+ if (status === "bypassed" && owner && readCodexUsageBannerPreference(ownerWindow)) {
+ banner.hide(owner.owner);
+ } else banner.restore();
return status;
};
const applyGate = (): RendererCodexUsageGateStatus => {
diff --git a/packages/renderer-extension/src/renderer-composer-dom.ts b/packages/renderer-extension/src/renderer-composer-dom.ts
index 0ff1afe50..1ccb32313 100644
--- a/packages/renderer-extension/src/renderer-composer-dom.ts
+++ b/packages/renderer-extension/src/renderer-composer-dom.ts
@@ -50,6 +50,7 @@ import {
mountRendererHarnessCommandControl,
type RendererHarnessCommandControl,
} from "./renderer-harness-command-control.js";
+import { readCodexUsageBannerPreference } from "./renderer-codex-usage-banner-preference.js";
export { CONTROL_ATTRIBUTE };
export type ExternalModelControlView = RendererModelControlView;
@@ -91,6 +92,7 @@ export interface ComposerAgentControl {
nativeModelControl: NativeModelControlState | null;
nativePermissionModeControl: NativePermissionModeControlState | null;
nativeContextUsageControl?: NativeControlState | null;
+ nativeUsageBanner?: NativeControlState | null;
nativePermissionModeControlVerified: boolean;
credits: RendererCreditsControl;
usage: RendererUsageControl | null;
@@ -358,6 +360,24 @@ export function nativeContextUsageControlForComposer(composer: Element): HTMLEle
return candidates.length === 1 ? (candidates[0] ?? null) : null;
}
+/**
+ * Codex usage / upsell recovery banner above the Composer: an aside with a
+ * title and CTA buttons. Some Desktop versions also mark it as a polite
+ * status; others omit those ARIA attributes. Ambiguous matches stay visible.
+ */
+export function isNativeUsageBannerCandidate(element: Element): boolean {
+ if (element.localName !== "aside") return false;
+ if (typeof element.querySelector !== "function") return false;
+ return element.querySelector("h3") !== null && element.querySelector("button") !== null;
+}
+
+export function nativeUsageBannerForComposer(composer: Element): HTMLElement | null {
+ const candidates = [...composer.querySelectorAll("aside")].filter(
+ isNativeUsageBannerCandidate,
+ );
+ return candidates.length === 1 ? (candidates[0] ?? null) : null;
+}
+
function contractElementVisible(element: Element): boolean {
const typed = element as HTMLElement;
const bounds = typed.getBoundingClientRect?.();
@@ -452,6 +472,13 @@ function refreshNativeContextUsageControl(control: ComposerAgentControl): void {
control.nativeContextUsageControl = captureNativeControl(candidate);
}
+function refreshNativeUsageBanner(control: ComposerAgentControl): void {
+ const candidate = nativeUsageBannerForComposer(control.composer);
+ if (candidate === control.nativeUsageBanner?.element) return;
+ restoreNativeControl(control.nativeUsageBanner);
+ control.nativeUsageBanner = captureNativeControl(candidate);
+}
+
function refreshNativeModelControl(control: ComposerAgentControl): void {
const candidate = nativeModelControlForComposer(control.composer);
if (!candidate) return;
@@ -640,6 +667,7 @@ export function reconcileComposerNativeControls(
// it below, so Credits never reads a stale (e.g. mount-time fallback)
// location for it within this same pass.
refreshNativePermissionModeControl(control);
+ refreshNativeUsageBanner(control);
refreshTrailingClusterPlacement(control);
refreshUsagePlacement(control);
refreshCreditsPlacement(control);
@@ -649,6 +677,20 @@ export function reconcileComposerNativeControls(
// visible when the external Model control is substituted.
setNativeControlHidden(control.nativeContextUsageControl, false);
setNativeControlHidden(control.nativePermissionModeControl, hidePermissionMode);
+ // Settings → Appearance, default on: hide the account-wide Codex usage
+ // aside on every Composer, including Codex threads. The aside reports the
+ // ChatGPT Account rate limit and upsell, not a per-thread notice.
+ setNativeControlHidden(
+ control.nativeUsageBanner,
+ readCodexUsageBannerPreference(preferenceOwner(control)),
+ );
+}
+
+function preferenceOwner(control: ComposerAgentControl): Window | null {
+ return (
+ control.composer.ownerDocument?.defaultView ??
+ (typeof document !== "undefined" ? document.defaultView : null)
+ );
}
export function mountComposerAgentControl(
@@ -672,6 +714,7 @@ export function mountComposerAgentControl(
const nativeContextUsageControl = captureNativeControl(
nativeContextUsageControlForComposer(composer),
);
+ const nativeUsageBanner = captureNativeControl(nativeUsageBannerForComposer(composer));
const semanticNativePermissionModeControl =
semanticNativePermissionModeControlForComposer(composer);
const nativePermissionModeControl = captureNativeControl(semanticNativePermissionModeControl);
@@ -746,6 +789,7 @@ export function mountComposerAgentControl(
nativeModelControl,
nativePermissionModeControl,
nativeContextUsageControl,
+ nativeUsageBanner,
nativePermissionModeControlVerified,
credits,
usage: null,
@@ -771,8 +815,8 @@ function nativeSendDisabled(button: HTMLButtonElement, fallback: boolean): boole
}
/**
- * Returns whether this Composer is ready to submit to an external Harness:
- * not Codex, Adapter ready, and no codexhost submission blocker.
+ * Returns whether this Composer should lift the native Codex usage submit
+ * gate: no codexhost submission blocker, and either Codex or a ready Adapter.
*/
export function renderComposerAgentControl(
control: ComposerAgentControl,
@@ -862,7 +906,7 @@ export function renderComposerAgentControl(
control.harnessCommands.root.hidden = state.agent === "codex";
control.harnessCommands.root.style.display = state.agent === "codex" ? "none" : "inline-flex";
renderRendererCreditsControl(control.credits, accountCredits, locale);
- return state.agent !== "codex" && adapterState === "ready" && !submissionBlocked;
+ return !submissionBlocked && (state.agent === "codex" || adapterState === "ready");
}
export function disposeComposerAgentControl(control: ComposerAgentControl): void {
@@ -874,6 +918,7 @@ export function disposeComposerAgentControl(control: ComposerAgentControl): void
}
restoreNativeControl(control.nativeModelControl);
restoreNativeControl(control.nativeContextUsageControl);
+ restoreNativeControl(control.nativeUsageBanner);
restoreNativeControl(control.nativePermissionModeControl);
control.credits.dispose();
control.usage?.dispose();
diff --git a/packages/renderer-extension/src/settings/appearance-page.ts b/packages/renderer-extension/src/settings/appearance-page.ts
index 51607c188..424eeee0b 100644
--- a/packages/renderer-extension/src/settings/appearance-page.ts
+++ b/packages/renderer-extension/src/settings/appearance-page.ts
@@ -1,4 +1,9 @@
import { readCpaLiveVoiceHookConfig } from "../renderer-cpa-live-voice-hook.js";
+import {
+ CODEX_USAGE_BANNER_CHANGE_EVENT,
+ readCodexUsageBannerPreference,
+ writeCodexUsageBannerPreference,
+} from "../renderer-codex-usage-banner-preference.js";
import {
CPA_LIVE_VOICE_CHANGE_EVENT,
readCpaLiveVoicePreference,
@@ -41,6 +46,32 @@ export function createAppearanceSettingsPage(
description.textContent = messages.appearanceDescription;
const { group, card } = createPreferenceGroup(document, messages.appearanceGroup);
+ const usageBannerId = preferenceId("hide-codex-usage-banner");
+ const usageBannerRow = createPreferenceItem(document, {
+ title: messages.hideCodexUsageBannerTitle,
+ description: messages.hideCodexUsageBannerDescription,
+ controlId: usageBannerId,
+ help: {
+ label: messages.hideCodexUsageBannerHelpLabel,
+ lines: messages.hideCodexUsageBannerHelp,
+ },
+ });
+ const usageBanner = createPreferenceSwitch(
+ document,
+ usageBannerId,
+ usageBannerRow.description.id,
+ );
+ usageBanner.checked = readCodexUsageBannerPreference(ownerWindow);
+ usageBanner.addEventListener("change", () => {
+ writeCodexUsageBannerPreference(ownerWindow, usageBanner.checked);
+ });
+ const syncUsageBanner = (): void => {
+ usageBanner.checked = readCodexUsageBannerPreference(ownerWindow);
+ };
+ ownerWindow.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, syncUsageBanner);
+ usageBannerRow.item.append(usageBanner);
+ card.append(usageBannerRow.item);
+
const liveVoiceId = preferenceId("cpa-live-voice");
const liveVoiceRow = createPreferenceItem(document, {
title: messages.cpaLiveVoiceTitle,
@@ -92,6 +123,7 @@ export function createAppearanceSettingsPage(
getLoadedSessionsClient,
);
return () => {
+ ownerWindow.removeEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, syncUsageBanner);
ownerWindow.removeEventListener(CPA_LIVE_VOICE_CHANGE_EVENT, syncLiveVoice);
ownerWindow.removeEventListener(REASONING_SOFT_WRAP_CHANGE_EVENT, sync);
disposeIdleRelease();
diff --git a/packages/renderer-extension/src/settings/localization.ts b/packages/renderer-extension/src/settings/localization.ts
index b82a89ed7..05da576a6 100644
--- a/packages/renderer-extension/src/settings/localization.ts
+++ b/packages/renderer-extension/src/settings/localization.ts
@@ -38,6 +38,10 @@ export interface RendererSettingsMessages {
readonly otherSection: string;
readonly appearanceDescription: string;
readonly appearanceGroup: string;
+ readonly hideCodexUsageBannerTitle: string;
+ readonly hideCodexUsageBannerDescription: string;
+ readonly hideCodexUsageBannerHelpLabel: string;
+ readonly hideCodexUsageBannerHelp: readonly string[];
readonly cpaLiveVoiceTitle: string;
readonly cpaLiveVoiceDescription: string;
readonly cpaLiveVoiceHelpLabel: string;
@@ -295,6 +299,14 @@ const ENGLISH_MESSAGES: RendererSettingsMessages = Object.freeze({
otherSection: "Other",
appearanceDescription: "Conversation display and local resource management.",
appearanceGroup: "Appearance",
+ hideCodexUsageBannerTitle: "Hide Codex usage warning",
+ hideCodexUsageBannerDescription: "On by default. Hide the out-of-usage card on every Composer.",
+ hideCodexUsageBannerHelpLabel: "About hiding the Codex usage warning",
+ hideCodexUsageBannerHelp: Object.freeze([
+ "The native card appears when the signed-in ChatGPT Codex quota is exhausted.",
+ "When this is on, that card is hidden on every Composer, including Codex threads. Reset usage and Upgrade stay available in Settings.",
+ "Turning this off only shows the native card again. Send on this Composer, including Codex threads, is already separated from that quota card.",
+ ]),
cpaLiveVoiceTitle: "Override live voice",
cpaLiveVoiceDescription: "On by default. Host creates the realtime call instead of ChatGPT.",
cpaLiveVoiceHelpLabel: "About overriding live voice",
@@ -613,6 +625,14 @@ const CHINESE_MESSAGES: RendererSettingsMessages = Object.freeze({
otherSection: "其他",
appearanceDescription: "会话显示与本地资源管理。",
appearanceGroup: "外观",
+ hideCodexUsageBannerTitle: "隐藏 Codex 额度警告",
+ hideCodexUsageBannerDescription: "默认开启。在所有 Composer 上隐藏额度耗尽卡片。",
+ hideCodexUsageBannerHelpLabel: "隐藏 Codex 额度警告说明",
+ hideCodexUsageBannerHelp: Object.freeze([
+ "已登录的 ChatGPT Codex 额度耗尽时,Desktop 会显示原生警告卡片。",
+ "开启后,所有 Composer 都隐藏该卡片,包括 Codex 会话。「重置用量」和「升级」仍可在设置中使用。",
+ "关闭此开关只会重新显示原生卡片。当前 Composer 的发送(包括 Codex 会话)已与该额度卡片分离。",
+ ]),
cpaLiveVoiceTitle: "改写实时语音",
cpaLiveVoiceDescription: "默认开启。由 Host 创建 realtime 通话,而不是 ChatGPT。",
cpaLiveVoiceHelpLabel: "改写实时语音说明",
diff --git a/packages/renderer-extension/test/renderer-binding-probe.test.ts b/packages/renderer-extension/test/renderer-binding-probe.test.ts
index b636b8519..539fe24d8 100644
--- a/packages/renderer-extension/test/renderer-binding-probe.test.ts
+++ b/packages/renderer-extension/test/renderer-binding-probe.test.ts
@@ -42,7 +42,9 @@ import {
isComposerVoiceButton,
creditsPlacementAnchor,
isNativeContextUsageControlCandidate,
+ isNativeUsageBannerCandidate,
nativeContextUsageControlForComposer,
+ nativeUsageBannerForComposer,
reconcileComposerNativeControls,
refreshSendButton,
trailingActionAnchor,
@@ -840,6 +842,150 @@ describe("Renderer Composer DOM behavior", () => {
expect(isNativeContextUsageControlCandidate(credits)).toBe(false);
});
+ it("recognizes only a unique Codex usage recovery aside", () => {
+ const banner = {
+ localName: "aside",
+ getAttribute: (name: string) =>
+ name === "role" ? "status" : name === "aria-live" ? "polite" : null,
+ querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
+ } as unknown as HTMLElement;
+ const unmarked = {
+ localName: "aside",
+ getAttribute: () => null,
+ querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
+ } as unknown as HTMLElement;
+ const notice = {
+ localName: "aside",
+ getAttribute: (name: string) =>
+ name === "role" ? "status" : name === "aria-live" ? "polite" : null,
+ querySelector: () => null,
+ } as unknown as HTMLElement;
+ expect(isNativeUsageBannerCandidate(banner)).toBe(true);
+ expect(isNativeUsageBannerCandidate(unmarked)).toBe(true);
+ expect(isNativeUsageBannerCandidate(notice)).toBe(false);
+ const composer = {
+ querySelectorAll: (selector: string) => (selector === "aside" ? [unmarked] : []),
+ } as unknown as Element;
+ expect(nativeUsageBannerForComposer(composer)).toBe(unmarked);
+ const ambiguous = {
+ querySelectorAll: () => [banner, unmarked],
+ } as unknown as Element;
+ expect(nativeUsageBannerForComposer(ambiguous)).toBeNull();
+ });
+
+ it("hides the Codex usage banner on any Composer by default, including Codex threads", () => {
+ vi.stubGlobal("document", { activeElement: null });
+ const banner = {
+ localName: "aside",
+ hidden: false,
+ getAttribute: (name: string) => {
+ if (name === "role") return "status";
+ if (name === "aria-live") return "polite";
+ if (name === "aria-hidden") return banner.ariaHidden;
+ if (name === "aria-expanded") return null;
+ return null;
+ },
+ setAttribute: (name: string, value: string) => {
+ if (name === "aria-hidden") banner.ariaHidden = value;
+ },
+ removeAttribute: (name: string) => {
+ if (name === "aria-hidden") banner.ariaHidden = null;
+ },
+ querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
+ contains: () => false,
+ click: vi.fn(),
+ ariaHidden: null as string | null,
+ };
+ const control = {
+ composer: {
+ querySelectorAll: (selector: string) => (selector === "aside" ? [banner] : []),
+ },
+ modelPicker: { root: { parentElement: null }, trigger: {} },
+ nativeModelControl: null,
+ nativePermissionModeControl: null,
+ nativeContextUsageControl: null,
+ nativeUsageBanner: null,
+ credits: {
+ anchor: null,
+ place: vi.fn(),
+ root: { remove: vi.fn() },
+ },
+ usage: {
+ anchor: null,
+ place: vi.fn(),
+ root: { remove: vi.fn() },
+ },
+ } as unknown as ComposerAgentControl;
+
+ reconcileComposerNativeControls(control, true, true);
+ expect(banner.hidden).toBe(true);
+ expect(banner.ariaHidden).toBe("true");
+ expect(control.nativeUsageBanner?.element).toBe(banner);
+
+ reconcileComposerNativeControls(control, false, false);
+ expect(banner.hidden).toBe(true);
+ expect(banner.ariaHidden).toBe("true");
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps the Codex usage banner when the Settings preference is off", () => {
+ vi.stubGlobal("document", { activeElement: null, defaultView: null });
+ const owner = Object.assign(new EventTarget(), {
+ localStorage: { getItem: () => "0", setItem: vi.fn() },
+ });
+ const banner = {
+ localName: "aside",
+ hidden: false,
+ getAttribute: (name: string) => {
+ if (name === "role") return "status";
+ if (name === "aria-live") return "polite";
+ if (name === "aria-hidden") return banner.ariaHidden;
+ if (name === "aria-expanded") return null;
+ return null;
+ },
+ setAttribute: (name: string, value: string) => {
+ if (name === "aria-hidden") banner.ariaHidden = value;
+ },
+ removeAttribute: (name: string) => {
+ if (name === "aria-hidden") banner.ariaHidden = null;
+ },
+ querySelector: (selector: string) => (selector === "h3" || selector === "button" ? {} : null),
+ contains: () => false,
+ click: vi.fn(),
+ ariaHidden: null as string | null,
+ };
+ const control = {
+ composer: {
+ ownerDocument: { defaultView: owner },
+ querySelectorAll: (selector: string) => (selector === "aside" ? [banner] : []),
+ },
+ modelPicker: { root: { parentElement: null }, trigger: {} },
+ nativeModelControl: null,
+ nativePermissionModeControl: null,
+ nativeContextUsageControl: null,
+ nativeUsageBanner: null,
+ credits: {
+ anchor: null,
+ place: vi.fn(),
+ root: { remove: vi.fn() },
+ },
+ usage: {
+ anchor: null,
+ place: vi.fn(),
+ root: { remove: vi.fn() },
+ },
+ } as unknown as ComposerAgentControl;
+
+ reconcileComposerNativeControls(control, true, true);
+ expect(banner.hidden).toBe(false);
+ expect(banner.ariaHidden).toBeNull();
+
+ reconcileComposerNativeControls(control, false, false);
+ expect(banner.hidden).toBe(false);
+ expect(banner.ariaHidden).toBeNull();
+ vi.unstubAllGlobals();
+ });
+
it("does not treat attachment controls as submission", () => {
const button = (label: string, type = "button") =>
({
diff --git a/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts b/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
new file mode 100644
index 000000000..6050d7c29
--- /dev/null
+++ b/packages/renderer-extension/test/renderer-codex-usage-banner-preference.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ CODEX_USAGE_BANNER_CHANGE_EVENT,
+ CODEX_USAGE_BANNER_STORAGE_KEY,
+ readCodexUsageBannerPreference,
+ writeCodexUsageBannerPreference,
+} from "../src/renderer-codex-usage-banner-preference.js";
+
+function fixture(initial?: string) {
+ const values = new Map();
+ if (initial !== undefined) values.set(CODEX_USAGE_BANNER_STORAGE_KEY, initial);
+ const storage = {
+ getItem: vi.fn((key: string) => values.get(key) ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ values.set(key, value);
+ }),
+ };
+ const owner = Object.assign(new EventTarget(), { localStorage: storage }) as unknown as Window;
+ return { owner, values };
+}
+
+describe("Codex usage banner Settings preference", () => {
+ it("defaults on, persists off as 0, and stays on without a Window", () => {
+ const unset = fixture();
+ expect(readCodexUsageBannerPreference(unset.owner)).toBe(true);
+ expect(readCodexUsageBannerPreference(null)).toBe(true);
+ expect(readCodexUsageBannerPreference(undefined)).toBe(true);
+
+ const events: string[] = [];
+ unset.owner.addEventListener(CODEX_USAGE_BANNER_CHANGE_EVENT, () => events.push("changed"));
+ expect(writeCodexUsageBannerPreference(unset.owner, false)).toBe(true);
+ expect(unset.values.get(CODEX_USAGE_BANNER_STORAGE_KEY)).toBe("0");
+ expect(readCodexUsageBannerPreference(unset.owner)).toBe(false);
+ expect(events).toEqual(["changed"]);
+
+ expect(writeCodexUsageBannerPreference(unset.owner, true)).toBe(true);
+ expect(unset.values.get(CODEX_USAGE_BANNER_STORAGE_KEY)).toBe("1");
+ expect(readCodexUsageBannerPreference(unset.owner)).toBe(true);
+ });
+
+ it("stays on when localStorage throws", () => {
+ const owner = Object.assign(new EventTarget(), {
+ localStorage: {
+ getItem: () => {
+ throw new Error("blocked");
+ },
+ setItem: () => {
+ throw new Error("blocked");
+ },
+ },
+ }) as unknown as Window;
+ expect(readCodexUsageBannerPreference(owner)).toBe(true);
+ expect(writeCodexUsageBannerPreference(owner, false)).toBe(false);
+ });
+});
diff --git a/packages/renderer-extension/test/settings/localization.test.ts b/packages/renderer-extension/test/settings/localization.test.ts
index 3ad04d3b3..006ccc5b6 100644
--- a/packages/renderer-extension/test/settings/localization.test.ts
+++ b/packages/renderer-extension/test/settings/localization.test.ts
@@ -33,6 +33,10 @@ describe("Renderer settings localization", () => {
expect(english.title).toBe("Settings");
expect(chinese.title).toBe("设置");
+ expect(english.hideCodexUsageBannerTitle).toBe("Hide Codex usage warning");
+ expect(chinese.hideCodexUsageBannerTitle).toBe("隐藏 Codex 额度警告");
+ expect(english.hideCodexUsageBannerDescription).toContain("On by default");
+ expect(chinese.hideCodexUsageBannerDescription).toContain("默认开启");
expect(english.cpaLiveVoiceTitle).toBe("Override live voice");
expect(chinese.cpaLiveVoiceTitle).toBe("改写实时语音");
expect(english.cpaLiveVoiceTitle.toLowerCase()).not.toContain("cpa");
diff --git a/tools/codex-desktop-contract-audit/README.md b/tools/codex-desktop-contract-audit/README.md
index 368060c07..9e15dcaa3 100644
--- a/tools/codex-desktop-contract-audit/README.md
+++ b/tools/codex-desktop-contract-audit/README.md
@@ -34,7 +34,7 @@ Controlled mode reuses the existing production `RendererControlSession`. It can
## Codex usage gate surface
-The `codex-usage-gate` surface covers `renderer-codex-usage-gate.ts`, which lets an external Harness submit while the ChatGPT-signed-in Codex subscription is out of usage. For each Composer with an editor it records bounded counts:
+The `codex-usage-gate` surface covers `renderer-codex-usage-gate.ts`, which lets a Composer submit while the ChatGPT-signed-in Codex subscription is out of usage. For each Composer with an editor it records bounded counts:
- `ownerCount` — Composer owners exposing `onLocalSubmitStart` and boolean `submitDisabled` that hold the reserve gate (pass-through wrappers with the same props are excluded);
- `reserveGateCount` — boolean subscriptions whose selector reads reserve `hardBlocked`;
@@ -46,7 +46,7 @@ Verdicts:
- no Account gate is `unverified` (`codex-usage-account-gate-dormant`): the selector returns early for API-key sign-in or while Account data loads, so inspect again signed in with ChatGPT;
- no Composer with an editor is `unverified`.
-The probe replays selectors read-only and never lifts gates, writes Account or query data, or submits input. Whether Send and Enter work for an external Agent with exhausted usage is behavioral and stays outside this tool.
+The probe replays selectors read-only and never lifts gates, writes Account or query data, or submits input. Whether Send and Enter work with exhausted usage on a new chat, existing Thread, or Codex Thread is behavioral and stays outside this tool.
## Transcript surface
From 0a7fc3cd32a367957c35e17a0444652d208f9bf1 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:53:04 +0900
Subject: [PATCH 25/98] fix: keep settings Back available without native
history
---
.../src/settings/back-navigation.ts | 114 ++++++++++++++++++
.../src/settings/rail-page.ts | 3 +
tests/e2e/renderer-settings-page.spec.ts | 80 ++++++++++++
3 files changed, 197 insertions(+)
create mode 100644 packages/renderer-extension/src/settings/back-navigation.ts
diff --git a/packages/renderer-extension/src/settings/back-navigation.ts b/packages/renderer-extension/src/settings/back-navigation.ts
new file mode 100644
index 000000000..c7a7b641a
--- /dev/null
+++ b/packages/renderer-extension/src/settings/back-navigation.ts
@@ -0,0 +1,114 @@
+const NATIVE_BACK_SELECTOR = '[data-app-shell-titlebar] button[aria-label="Back"]';
+const SETTINGS_BACK_ATTRIBUTE = "data-codexhost-settings-back";
+
+/** Settings has a Back action even when the covered native page has no history. */
+export function attachSettingsBackNavigation(
+ ownerDocument: Document,
+ onBack: () => void,
+): { dispose(): void } {
+ const ownerWindow = ownerDocument.defaultView;
+ let source: HTMLButtonElement | null = null;
+ let button: HTMLButtonElement | null = null;
+ let disposed = false;
+
+ const onClick = (event: MouseEvent): void => {
+ event.preventDefault();
+ event.stopPropagation();
+ onBack();
+ };
+ const place = (): void => {
+ if (disposed) return;
+ const nextSource =
+ [...ownerDocument.querySelectorAll(NATIVE_BACK_SELECTOR)].find(
+ (candidate) => {
+ const bounds = candidate.getBoundingClientRect();
+ const parent = candidate.parentElement;
+ return (
+ bounds.width > 0 &&
+ bounds.height > 0 &&
+ !candidate.closest('[aria-hidden="true"]') &&
+ parent !== null &&
+ ownerWindow?.getComputedStyle(parent).visibility !== "hidden"
+ );
+ },
+ ) ?? null;
+ if (nextSource !== source) {
+ button?.removeEventListener("click", onClick);
+ button?.remove();
+ source = nextSource;
+ button = source?.cloneNode(true) as HTMLButtonElement | null;
+ if (button) {
+ // Copy native styling and its localized label, without native disabled state
+ // or React handlers. The original control remains owned by Desktop.
+ for (const element of [button, ...button.querySelectorAll("[id]")]) {
+ element.removeAttribute("id");
+ }
+ for (const attribute of [
+ "disabled",
+ "aria-disabled",
+ "data-disabled",
+ "tabindex",
+ "aria-describedby",
+ ]) {
+ button.removeAttribute(attribute);
+ }
+ button.setAttribute(SETTINGS_BACK_ATTRIBUTE, "");
+ button.title = source?.getAttribute("aria-label") ?? "Back";
+ button.style.position = "fixed";
+ button.style.zIndex = "41";
+ button.style.margin = "0";
+ button.style.setProperty("-webkit-app-region", "no-drag");
+ button.addEventListener("click", onClick);
+ ownerDocument.body.append(button);
+ }
+ }
+ if (!source || !button) return;
+ const bounds = source.getBoundingClientRect();
+ Object.assign(button.style, {
+ left: `${bounds.left}px`,
+ top: `${bounds.top}px`,
+ width: `${bounds.width}px`,
+ height: `${bounds.height}px`,
+ });
+ };
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.defaultPrevented || event.shiftKey) return;
+ const macBack = event.metaKey && !event.ctrlKey && !event.altKey && event.key === "[";
+ const altBack = event.altKey && !event.metaKey && !event.ctrlKey && event.key === "ArrowLeft";
+ if (!macBack && !altBack) return;
+ event.preventDefault();
+ event.stopPropagation();
+ onBack();
+ };
+
+ const style = ownerDocument.createElement("style");
+ // Visibility preserves geometry but removes the covered control from hit
+ // testing and the accessibility tree.
+ style.textContent = `${NATIVE_BACK_SELECTOR} { visibility: hidden !important; }`;
+ ownerDocument.head.append(style);
+ place();
+ const observer = ownerWindow
+ ? new ownerWindow.MutationObserver((records) => {
+ const titlebar = source?.closest("[data-app-shell-titlebar]");
+ if (!source?.isConnected || records.some((record) => titlebar?.contains(record.target))) {
+ place();
+ }
+ })
+ : null;
+ observer?.observe(ownerDocument.body, { childList: true, subtree: true });
+ ownerWindow?.addEventListener("resize", place);
+ ownerDocument.addEventListener("keydown", onKeyDown, true);
+
+ return {
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ observer?.disconnect();
+ ownerWindow?.removeEventListener("resize", place);
+ ownerDocument.removeEventListener("keydown", onKeyDown, true);
+ button?.removeEventListener("click", onClick);
+ button?.remove();
+ style.remove();
+ },
+ };
+}
diff --git a/packages/renderer-extension/src/settings/rail-page.ts b/packages/renderer-extension/src/settings/rail-page.ts
index 71909f4a0..581d2491a 100644
--- a/packages/renderer-extension/src/settings/rail-page.ts
+++ b/packages/renderer-extension/src/settings/rail-page.ts
@@ -1,4 +1,5 @@
import { SETTINGS_TRIGGER_ATTRIBUTE } from "./trigger.js";
+import { attachSettingsBackNavigation } from "./back-navigation.js";
const NAVIGATION_RAIL_SELECTOR = "nav[data-app-navigation-rail]";
const RAIL_DESTINATION_SELECTOR = "[data-sidebar-destination]";
@@ -78,6 +79,7 @@ export function attachRendererSettingsRailPage(
if (disposed) return;
onNavigateAway();
};
+ const backNavigation = attachSettingsBackNavigation(ownerDocument, navigateAway);
const checkNavigation = (): void => {
if (disposed) return;
@@ -158,6 +160,7 @@ export function attachRendererSettingsRailPage(
dispose() {
if (disposed) return;
disposed = true;
+ backNavigation.dispose();
railMutations?.disconnect();
railResize?.disconnect();
bodyMutations?.disconnect();
diff --git a/tests/e2e/renderer-settings-page.spec.ts b/tests/e2e/renderer-settings-page.spec.ts
index 6d8cb395c..ad640340a 100644
--- a/tests/e2e/renderer-settings-page.spec.ts
+++ b/tests/e2e/renderer-settings-page.spec.ts
@@ -41,6 +41,10 @@ const { outputFiles } = await build({
// returning from another destination restores the previous conversation.
const nativeNavigations = [];
history.replaceState({}, "", "/thread/settings-test");
+ document.querySelector('[data-app-shell-titlebar] button').addEventListener("click", () => {
+ nativeNavigations.push("back");
+ history.pushState({}, "", "/thread/previous");
+ });
document.body.addEventListener("click", (event) => {
const destination = event.target.closest("[data-sidebar-destination]");
if (!destination) return;
@@ -78,6 +82,8 @@ if (!bundle) throw new Error("Settings page fixture bundle missing");
// [data-selected]::before and expose the current destination via aria-current.
const nativeShell = `
+
H
P
@@ -129,6 +138,77 @@ function fixtureState(page: Page) {
});
}
+for (const nativeDisabled of [true, false]) {
+ for (const activation of ["click", "Enter", "Space", "Meta+[", "Alt+ArrowLeft"] as const) {
+ test(`settings Back works with native Back ${nativeDisabled ? "disabled" : "enabled"} (${activation})`, async ({
+ page,
+ }) => {
+ await setup(page);
+ const nativeBack = page.locator("[data-app-shell-titlebar] button");
+ await nativeBack.evaluate((button: HTMLButtonElement, disabled) => {
+ button.disabled = disabled;
+ button.setAttribute("aria-disabled", String(disabled));
+ if (disabled) {
+ button.setAttribute("data-disabled", "");
+ button.tabIndex = -1;
+ }
+ }, nativeDisabled);
+ const bounds = await nativeBack.boundingBox();
+ await openSettings(page);
+ const back = page.getByRole("button", { name: "Back", exact: true });
+ await expect(back).toBeEnabled();
+ expect(await back.boundingBox()).toEqual(bounds);
+ if (activation === "click") await back.locator("svg").click();
+ else {
+ await back.focus();
+ await page.keyboard.press(activation);
+ }
+ await expect(page.locator(".codexhost-settings-page")).toBeHidden();
+ await expect(page.locator("[data-codexhost-settings-back]")).toHaveCount(0);
+ await expect(nativeBack).toBeVisible();
+ expect(await nativeBack.isDisabled()).toBe(nativeDisabled);
+ expect(await fixtureState(page)).toMatchObject({
+ events: [true, false],
+ pathname: "/thread/settings-test",
+ nativeNavigations: [],
+ });
+ if (!nativeDisabled) {
+ await nativeBack.click();
+ expect(await fixtureState(page)).toMatchObject({
+ pathname: "/thread/previous",
+ nativeNavigations: ["back"],
+ });
+ }
+ });
+ }
+}
+
+test("keeps settings Back available when Desktop remounts its titlebar", async ({ page }) => {
+ await setup(page);
+ await openSettings(page);
+ await page.locator("[data-app-shell-titlebar]").evaluate((header) => {
+ const replacement = header.cloneNode(true) as HTMLElement;
+ const button = replacement.querySelector("button");
+ if (!button) throw new Error("Native Back fixture missing");
+ button.disabled = true;
+ header.replaceWith(replacement);
+ });
+ const back = page.getByRole("button", { name: "Back", exact: true });
+ await expect(back).toBeEnabled();
+ await back.click();
+ await expect(page.locator(".codexhost-settings-page")).toBeHidden();
+});
+
+test("disposing open settings restores native Back and removes its settings action", async ({
+ page,
+}) => {
+ await setup(page);
+ await openSettings(page);
+ await page.evaluate(() => Reflect.get(globalThis, "settingsFixture").shell.dispose());
+ await expect(page.locator("[data-codexhost-settings-back]")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
+});
+
test("covers the content beside the rail and hides native selection without editing it", async ({
page,
}) => {
From 9749ffae1d05ef9771acee8a182feba725576a08 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:53:04 +0900
Subject: [PATCH 26/98] chore: prepare v0.13.3-3
---
Cargo.lock | 10 +++++-----
Cargo.toml | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index d06579422..f1adee865 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "codexhost-gate-a-native"
-version = "0.13.3-2"
+version = "0.13.3-3"
dependencies = [
"codexhost-platform",
"codexhost-shim",
@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "codexhost-launcher"
-version = "0.13.3-2"
+version = "0.13.3-3"
dependencies = [
"codexhost-platform",
"fs2",
@@ -106,7 +106,7 @@ dependencies = [
[[package]]
name = "codexhost-platform"
-version = "0.13.3-2"
+version = "0.13.3-3"
dependencies = [
"libproc",
"nix",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "codexhost-shim"
-version = "0.13.3-2"
+version = "0.13.3-3"
dependencies = [
"codexhost-platform",
"fs2",
@@ -131,7 +131,7 @@ dependencies = [
[[package]]
name = "codexhost-updater"
-version = "0.13.3-2"
+version = "0.13.3-3"
dependencies = [
"codexhost-platform",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 10d8eb984..c400377dd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ default-members = [
resolver = "3"
[workspace.package]
-version = "0.13.3-2"
+version = "0.13.3-3"
edition = "2024"
rust-version = "1.97.1"
publish = false
diff --git a/package-lock.json b/package-lock.json
index 12e671dd9..cb4fa82ff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexhost",
- "version": "0.13.3-2",
+ "version": "0.13.3-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexhost",
- "version": "0.13.3-2",
+ "version": "0.13.3-3",
"license": "LGPL-3.0-only",
"workspaces": [
"packages/*",
diff --git a/package.json b/package.json
index 93ec04e8b..89fe147da 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexhost",
- "version": "0.13.3-2",
+ "version": "0.13.3-3",
"private": true,
"license": "LGPL-3.0-only",
"type": "module",
From 87e321f9eb36782d195c93792ded05fe8d10b757 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Mon, 19 Oct 2026 01:17:29 +0900
Subject: [PATCH 27/98] fix: keep voice handoffs and chat updates flowing after
hangup
---
.../operations/cpa-live-voice-desktop-hook.md | 34 +-
packages/host-runtime/src/app-server-host.ts | 19 +-
.../src/external-voice-gateway.ts | 3 +
.../src/external-voice-results.ts | 50 ++-
.../src/external-voice-session-input.ts | 65 +++
packages/host-runtime/src/external-voice.ts | 156 +++++--
.../app-server-host.external-voice.test.ts | 175 +++++++-
.../test/app-server-host.projection-4.test.ts | 70 +++-
.../test/external-voice-gateway.test.ts | 28 ++
.../test/external-voice-lifecycle.test.ts | 385 +++++++++++++++++-
.../test/external-voice-results.test.ts | 64 +++
.../test/external-voice-session-input.test.ts | 42 ++
.../src/renderer-overlay-thread-isolation.ts | 20 +-
.../renderer-overlay-thread-isolation.test.ts | 94 +++++
14 files changed, 1132 insertions(+), 73 deletions(-)
create mode 100644 packages/host-runtime/src/external-voice-session-input.ts
create mode 100644 packages/host-runtime/test/external-voice-results.test.ts
create mode 100644 packages/host-runtime/test/external-voice-session-input.test.ts
diff --git a/docs/operations/cpa-live-voice-desktop-hook.md b/docs/operations/cpa-live-voice-desktop-hook.md
index 16b883f11..b86cd380e 100644
--- a/docs/operations/cpa-live-voice-desktop-hook.md
+++ b/docs/operations/cpa-live-voice-desktop-hook.md
@@ -23,7 +23,7 @@ The hook forces Statsig `checkGate("206132468")` to false on every client regist
1. Unless `CODEXHOST_CPA_LIVE_VOICE=0`, the Controller prefixes the production Renderer source with a frozen `globalThis.__codexhostCpaLiveVoiceHookV1` record (audited Desktop version and `app-initial` asset name only).
2. `Page.addScriptToEvaluateOnNewDocument` plus immediate `Runtime.evaluate` inject the Host Renderer into the primary `app://-/index.html` page.
-3. Voice starts on the avatar overlay (`app://-/index.html?initialRoute=/avatar-overlay`). That page gets a **hook-only** bundle (`cpa-live-voice-hook.js`), not the Host Renderer binding. The overlay loads the same audited `app-initial` asset in its own JavaScript realm with its own Statsig client. The overlay bundle also drops Host `thread/started` and turn/item notifications unless that overlay currently owns the realtime Thread, so a leftover overlay does not upsert CLI-created Threads or rebind the same Turn into the main chat.
+3. Voice starts on the avatar overlay (`app://-/index.html?initialRoute=/avatar-overlay`). That page gets a **hook-only** bundle (`cpa-live-voice-hook.js`), not the Host Renderer binding. The overlay loads the same audited `app-initial` asset in its own JavaScript realm with its own Statsig client. The overlay bundle also drops Host `thread/started` and turn/item notifications for Threads that have not hosted voice in that overlay, so a leftover overlay does not upsert CLI-created Threads or rebind the same Turn into the main chat. Known voice Threads keep receiving notifications after hangup because native stream ownership can outlive the call.
4. The Renderer hook no-ops unless the exact audited `app://-/assets/app-initial-6350d04672d0.js` asset is present (performance entries, script src, or link href, including relative URLs). Unknown assets fail closed; matching text in a query string or source-map filename is insufficient.
5. On a matching page it polls for up to 60 seconds until a Statsig client is registered, then wraps that client's `checkGate`. The gate is read when voice starts, so the wrap only has to land before the first voice click.
@@ -86,18 +86,40 @@ Turn/approval projection; it does not start a second agent or import a concrete
The transport targets CPA's frameless V3 client-delegation protocol. It posts the
Desktop SDP and client-delegation session to the configured realtime call base,
returns the answer through native `started`/`sdp` notifications, and joins the
-configured WebSocket base at `/live/{callId}`. Both configured bases must use HTTPS
-and share an origin. Authentication uses the selected configured model provider's
+configured WebSocket base at `/live/{callId}`. Reopened calls on an existing
+External Thread include that Thread's user and agent messages as GPT-Live
+`session.initial_items`, using the V3 role-bearing message format. A native
+`delegation.created` with task text and `user_bidi_turn_id` starts Harness work
+and binds its speakable handle (`item.id`) to that user turn. The service can
+wait for the client answer before emitting user `turn.done`, so that final
+transcript is not a prerequisite for a linked handoff. A completed user
+`turn.done` starts work only if the same turn has not already been registered.
+Matching uses protocol turn identifiers, including `turn_id` / `turnId`, not
+text similarity or timing. A handle without a turn identifier is ignored for
+routing. Both configured
+bases must use HTTPS and share an origin. Authentication uses the
+selected configured model provider's
`experimental_bearer_token` or `env_key`; OAuth from `auth.json` is never copied.
The Host reads `CODEX_HOME/config.toml` only and does not mutate gateway settings.
Profile/command-line credential overrides are not resolved by this prototype.
-V3 `delegation.created` task text goes to that same external Thread. Only completed
+V3 handoff text and user `turn.done` transcripts go to that same external Thread. Only completed
agent-message results are returned as speakable context; private reasoning and tool
-output are excluded. Delegation IDs are deduplicated within a call. Transcripts are
+output are excluded. Delegation IDs are deduplicated within a call and used only
+to address `delegation.context.append` when the handle names the user turn.
+Results without a linked handle are appended with the supported
+`session.context.append` event. A handoff with no task text or no turn identifier
+does not start work and does not bind a later utterance. Partial `turn.created`
+and `turn.delta` transcripts do not start work. Transcripts are
projected through native notifications. Closing the call releases voice resources
and suppresses late speech, but does not cancel a running agent task: explicit task
-cancellation remains the existing `turn/interrupt` operation.
+cancellation remains the existing `turn/interrupt` operation. The avatar overlay
+keeps delivering known voice Threads' `turn/*` and `item/*` notifications after
+hangup, including later text turns. The primary window may remain a native
+stream follower of the overlay, so call closure or an idle status does not
+release notification delivery. Deleting a Thread removes it from this set.
+`turn/interrupt` for a Turn that is already complete, or that is not the active
+Turn, returns success without cancelling a newer Turn.
The speech service is instructed to delegate every question and task, including
simple arithmetic; it supplies speech rather than owning task reasoning.
diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts
index 58310f6a0..2c5e63517 100644
--- a/packages/host-runtime/src/app-server-host.ts
+++ b/packages/host-runtime/src/app-server-host.ts
@@ -15,6 +15,7 @@ import { requestDesktopRemoteConnections } from "@codexhost/desktop-control";
import type { RuntimeMaintenance } from "./runtime-maintenance.js";
import { ExternalThreadPrewarms } from "./external-thread-prewarms.js";
import { ExternalVoice } from "./external-voice.js";
+import { voiceSessionInputFromTurns } from "./external-voice-session-input.js";
import { ExternalVoiceResults } from "./external-voice-results.js";
import {
configuredExternalVoiceGateway,
@@ -4587,7 +4588,11 @@ export class AppServerHost {
): Promise {
try {
if (request.method === "thread/realtime/start") {
- await this.#externalVoice.start(thread.id, requestObject(request));
+ await this.#externalVoice.start(
+ thread.id,
+ requestObject(request),
+ voiceSessionInputFromTurns(thread.turns),
+ );
} else if (request.method === "thread/realtime/stop") {
await this.#externalVoice.stop(thread.id);
} else {
@@ -4892,9 +4897,9 @@ export class AppServerHost {
!thread.running ||
thread.activeTurnId !== requestedTurnId
) {
- await this.#writer.json(
- rpcError(request, -32074, "External turn/interrupt must reference the active Turn"),
- );
+ // Late overlay stop after the Turn already completed. Desktop retries a
+ // mismatch error against whichever Turn is now active.
+ await this.#writer.json(rpcEnvelope(request, { result: {} }));
return;
}
const turnId = thread.activeTurnId;
@@ -5231,7 +5236,11 @@ export class AppServerHost {
thread.projectedTurns.delete(event.turnId);
}
for (const message of result.messages) await this.#writer.json(message);
- this.#externalVoiceResults.observe(thread.id, event);
+ this.#externalVoiceResults.observe(
+ thread.id,
+ event,
+ event.type === "turn.completed" ? result.completedTurn : undefined,
+ );
if (event.type === "turn.completed") {
await this.#setThreadStatus(
thread,
diff --git a/packages/host-runtime/src/external-voice-gateway.ts b/packages/host-runtime/src/external-voice-gateway.ts
index 931b0e4ce..9888bc384 100644
--- a/packages/host-runtime/src/external-voice-gateway.ts
+++ b/packages/host-runtime/src/external-voice-gateway.ts
@@ -17,6 +17,8 @@ export type ExternalVoiceCallFactory = (input: {
sdp: string;
voice?: string;
signal: AbortSignal;
+ /** Prior Thread messages for GPT-Live `session.initial_items`; omitted when empty. */
+ input?: JsonObject[];
onEvent(event: JsonObject): void;
onError(): void;
}) => Promise;
@@ -96,6 +98,7 @@ export function configuredExternalVoiceGateway(
"Speak only verified client results; do not read private reasoning or machine identifiers.",
audio: { output: { voice: input.voice ?? "cove" } },
delegation: { type: "client", ack_filler: false },
+ ...(input.input && input.input.length > 0 ? { initial_items: input.input } : {}),
}),
);
const response = await fetch(`${url.href.replace(/\/$/u, "")}/realtime/calls`, {
diff --git a/packages/host-runtime/src/external-voice-results.ts b/packages/host-runtime/src/external-voice-results.ts
index 4504b52b0..970cb9b0b 100644
--- a/packages/host-runtime/src/external-voice-results.ts
+++ b/packages/host-runtime/src/external-voice-results.ts
@@ -1,5 +1,33 @@
import type { HostEvent } from "@codexhost/harness-adapter";
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Speakable Agent Message text already projected for Desktop, including inferred final answers. */
+export function speakableFromProjectedTurn(turn: unknown): string | undefined {
+ if (!isRecord(turn) || !Array.isArray(turn.items)) return undefined;
+ const messages = turn.items.filter(
+ (item): item is Record & { text: string } =>
+ isRecord(item) && item.type === "agentMessage" && typeof item.text === "string",
+ );
+ return speakableFromItems(messages);
+}
+
+function speakableFromItems(
+ items: Iterable<{ text: string; phase?: unknown }>,
+): string | undefined {
+ const values = [...items];
+ const finals = values.filter((item) => item.phase === "final_answer");
+ const text = (
+ finals.length ? finals : values.filter((item) => item.phase !== "commentary").slice(-1)
+ )
+ .map((item) => item.text)
+ .join("\n")
+ .trim();
+ return text.length > 0 ? text : undefined;
+}
+
/** Fan-out from the existing Host consumer; never consumes HarnessSession.outputs. */
export class ExternalVoiceResults {
readonly #pending = new Map<
@@ -31,7 +59,7 @@ export class ExternalVoiceResults {
});
}
- observe(threadId: string, event: HostEvent): void {
+ observe(threadId: string, event: HostEvent, projectedTurn?: unknown): void {
if (event.type === "session.faulted") {
for (const [turnId, pending] of this.#pending) {
if (pending.threadId !== threadId) continue;
@@ -44,6 +72,16 @@ export class ExternalVoiceResults {
if (!("turnId" in event)) return;
const pending = this.#pending.get(event.turnId);
if (!pending || pending.threadId !== threadId) return;
+ if (event.type === "item.started" && event.item.type === "agentMessage") {
+ pending.items.set(event.item.itemId, {
+ text: event.item.text,
+ ...(event.item.phase ? { phase: event.item.phase } : {}),
+ });
+ }
+ if (event.type === "item.updated" && event.update.type === "text.append") {
+ const item = pending.items.get(event.itemId);
+ if (item) item.text += event.update.text;
+ }
if (
event.type === "item.completed" &&
event.snapshot.item.type === "agentMessage" &&
@@ -62,14 +100,8 @@ export class ExternalVoiceResults {
pending.reject(new Error("External agent task did not complete"));
return;
}
- const items = [...pending.items.values()];
- const finals = items.filter((item) => item.phase === "final_answer");
- const text = (
- finals.length ? finals : items.filter((item) => item.phase !== "commentary").slice(-1)
- )
- .map((item) => item.text)
- .join("\n")
- .trim();
+ const text =
+ speakableFromItems(pending.items.values()) ?? speakableFromProjectedTurn(projectedTurn);
if (!text) {
pending.reject(new Error("External agent returned no spoken result"));
return;
diff --git a/packages/host-runtime/src/external-voice-session-input.ts b/packages/host-runtime/src/external-voice-session-input.ts
new file mode 100644
index 000000000..0014726cc
--- /dev/null
+++ b/packages/host-runtime/src/external-voice-session-input.ts
@@ -0,0 +1,65 @@
+import type { JsonObject } from "@codexhost/protocol-core";
+
+const MAX_MESSAGES = 32;
+const MAX_MESSAGE_CHARS = 2_000;
+const MAX_TOTAL_CHARS = 12_000;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function userMessageText(item: Record): string {
+ if (!Array.isArray(item.content)) return "";
+ return item.content
+ .filter(isRecord)
+ .map((part) => (part.type === "text" && typeof part.text === "string" ? part.text.trim() : ""))
+ .filter((text) => text.length > 0)
+ .join("\n");
+}
+
+function clip(text: string): string {
+ return text.length <= MAX_MESSAGE_CHARS ? text : text.slice(0, MAX_MESSAGE_CHARS);
+}
+
+function liveMessage(role: "user" | "assistant", text: string): JsonObject {
+ return {
+ type: "message",
+ role,
+ content: [{ type: role === "user" ? "input_text" : "output_text", text: clip(text) }],
+ };
+}
+
+function messageChars(message: JsonObject): number {
+ const content = message.content;
+ if (!Array.isArray(content) || !isRecord(content[0]) || typeof content[0].text !== "string") {
+ return 0;
+ }
+ return content[0].text.length;
+}
+
+/** GPT-Live `session.initial_items` history from an existing External Thread's projected Turns. */
+export function voiceSessionInputFromTurns(turns: readonly unknown[]): JsonObject[] {
+ const messages: JsonObject[] = [];
+ for (const turn of turns) {
+ if (!isRecord(turn) || !Array.isArray(turn.items)) continue;
+ for (const item of turn.items) {
+ if (!isRecord(item)) continue;
+ if (item.type === "userMessage") {
+ const text = userMessageText(item);
+ if (text) messages.push(liveMessage("user", text));
+ } else if (item.type === "agentMessage" && item.phase !== "commentary") {
+ const text = typeof item.text === "string" ? item.text.trim() : "";
+ if (text) messages.push(liveMessage("assistant", text));
+ }
+ }
+ }
+ const selected: JsonObject[] = [];
+ let total = 0;
+ for (const message of messages.slice(-MAX_MESSAGES).reverse()) {
+ const size = messageChars(message);
+ if (total + size > MAX_TOTAL_CHARS) break;
+ total += size;
+ selected.push(message);
+ }
+ return selected.reverse();
+}
diff --git a/packages/host-runtime/src/external-voice.ts b/packages/host-runtime/src/external-voice.ts
index 635dae153..7b2d692e9 100644
--- a/packages/host-runtime/src/external-voice.ts
+++ b/packages/host-runtime/src/external-voice.ts
@@ -2,11 +2,26 @@ import { randomUUID } from "node:crypto";
import type { JsonObject } from "@codexhost/protocol-core";
import type { ExternalVoiceCall, ExternalVoiceCallFactory } from "./external-voice-gateway.js";
+interface VoiceTask {
+ id: string;
+ text: string;
+ result?: string;
+ delegationId?: string;
+ spokenFor?: string | null;
+}
+
+interface PendingHandle {
+ id: string;
+ turnId: string;
+}
+
interface VoiceSession {
controller: AbortController;
call?: ExternalVoiceCall;
creation?: Promise;
seen: Set;
+ tasks: Map;
+ pendingHandles: PendingHandle[];
initializationTimeout?: ReturnType;
stopping?: Promise;
}
@@ -27,6 +42,24 @@ const voices = new Set([
"cove",
]);
+function protocolId(value: unknown): string | undefined {
+ return typeof value === "string" && value.length > 0 && value.length <= 256 ? value : undefined;
+}
+
+function userTurnId(event: JsonObject, turn: JsonObject): string | undefined {
+ return protocolId(turn.id) ?? protocolId(event.event_id);
+}
+
+function delegationTurnId(event: JsonObject, item: JsonObject): string | undefined {
+ return (
+ protocolId(item.user_bidi_turn_id) ??
+ protocolId(item.turn_id) ??
+ protocolId(item.turnId) ??
+ protocolId(event.turn_id) ??
+ (object(event.turn) ? protocolId(event.turn.id) : undefined)
+ );
+}
+
/** External-only native call lifecycle. Native Codex requests never enter this owner. */
export class ExternalVoice {
readonly #sessions = new Map();
@@ -43,7 +76,7 @@ export class ExternalVoice {
return this.#sessions.has(threadId);
}
- async start(threadId: string, params: JsonObject): Promise {
+ async start(threadId: string, params: JsonObject, conversation?: JsonObject[]): Promise {
if (this.has(threadId)) throw new Error("This chat already has a voice call");
if (
!object(params.transport) ||
@@ -58,7 +91,12 @@ export class ExternalVoice {
if (params.voice != null && (typeof params.voice !== "string" || !voices.has(params.voice))) {
throw new Error("Selected voice is unavailable for the external live-call protocol");
}
- const session: VoiceSession = { controller: new AbortController(), seen: new Set() };
+ const session: VoiceSession = {
+ controller: new AbortController(),
+ seen: new Set(),
+ tasks: new Map(),
+ pendingHandles: [],
+ };
this.#sessions.set(threadId, session);
const requireActive = (): void => {
if (session.controller.signal.aborted || this.#sessions.get(threadId) !== session)
@@ -68,6 +106,7 @@ export class ExternalVoice {
session.creation = this.options.createCall({
sdp: params.transport.sdp,
...(typeof params.voice === "string" ? { voice: params.voice } : {}),
+ ...(conversation && conversation.length > 0 ? { input: conversation } : {}),
signal: session.controller.signal,
onEvent: (event) => {
void this.#event(threadId, session, event).catch(() => this.#fail(threadId, session));
@@ -170,6 +209,10 @@ export class ExternalVoice {
return;
}
if (event.type === "turn.done" && object(event.turn)) {
+ const userTask =
+ String(event.turn.role) === "user" && typeof event.turn.transcript === "string"
+ ? this.#registerTask(session, userTurnId(event, event.turn), event.turn.transcript)
+ : undefined;
if (
["user", "assistant"].includes(String(event.turn.role)) &&
typeof event.turn.transcript === "string"
@@ -180,53 +223,110 @@ export class ExternalVoice {
text: event.turn.transcript,
});
}
+ if (userTask) await this.#startTask(threadId, session, userTask);
return;
}
if (event.type !== "delegation.created" || !object(event.item)) return;
const item = event.item;
- if (
- item.type !== "delegation" ||
- item.target !== "client" ||
- typeof item.id !== "string" ||
- !item.id ||
- item.id.length > 256
- )
- return;
- if (session.seen.has(item.id)) return;
+ const delegationId = protocolId(item.id);
+ if (item.type !== "delegation" || item.target !== "client" || !delegationId) return;
+ if (session.seen.has(delegationId)) return;
if (session.seen.size >= 512) {
await this.#fail(threadId, session);
return;
}
- session.seen.add(item.id);
+ session.seen.add(delegationId);
+ const turnId = delegationTurnId(event, item);
+ if (turnId) this.#attachHandle(session, turnId, delegationId);
const text = Array.isArray(item.content)
? item.content
.filter(object)
- .filter((part) => part.type === "input_text" && typeof part.text === "string")
- .map((part) => part.text)
- .join("")
- .trim()
+ .map((part) =>
+ part.type === "input_text" && typeof part.text === "string" ? part.text : "",
+ )
+ .filter(Boolean)
+ .join("\n")
: "";
- if (!text || text.length > 32_000)
- throw new Error("External voice delegation has no valid task text");
+ if (turnId && text) {
+ const task = this.#registerTask(session, turnId, text);
+ if (task) await this.#startTask(threadId, session, task);
+ }
+ }
+
+ #attachHandle(session: VoiceSession, turnId: string, delegationId: string): void {
+ const byId = session.tasks.get(turnId);
+ if (byId) {
+ if (!byId.delegationId) {
+ byId.delegationId = delegationId;
+ if (byId.result) this.#speak(session, byId);
+ }
+ return;
+ }
+ if (session.pendingHandles.length < 512)
+ session.pendingHandles.push({ id: delegationId, turnId });
+ }
+
+ #takePendingHandle(session: VoiceSession, task: VoiceTask): void {
+ if (task.delegationId) return;
+ const byTurn = session.pendingHandles.findIndex((handle) => handle.turnId === task.id);
+ if (byTurn < 0) return;
+ const [handle] = session.pendingHandles.splice(byTurn, 1);
+ if (handle) task.delegationId = handle.id;
+ }
+
+ #registerTask(
+ session: VoiceSession,
+ turnId: string | undefined,
+ text: string,
+ ): VoiceTask | undefined {
+ const transcript = text.trim();
+ if (!transcript || transcript.length > 32_000) return;
+ const id = turnId ?? `user-turn:${randomUUID()}`;
+ if (session.tasks.has(id) || session.tasks.size >= 512) return;
+ const task: VoiceTask = { id, text: transcript };
+ session.tasks.set(id, task);
+ this.#takePendingHandle(session, task);
+ return task;
+ }
+
+ async #startTask(threadId: string, session: VoiceSession, task: VoiceTask): Promise {
+ if (session.controller.signal.aborted || this.#sessions.get(threadId) !== session) return;
let result: string;
try {
- result = await this.options.runTask(threadId, text, session.controller.signal);
+ result = await this.options.runTask(threadId, task.text, session.controller.signal);
} catch {
result =
"The selected agent could not complete this request. No completion is confirmed. Check the chat for its current task status.";
}
+ task.result = result;
if (session.controller.signal.aborted || this.#sessions.get(threadId) !== session) return;
- // Conservative Unicode chunks stay below the protocol's context append limit.
- const characters = [...result].slice(0, 8_000);
+ this.#speak(session, task);
+ }
+
+ #speak(session: VoiceSession, task: VoiceTask): void {
+ const result = task.result;
const call = session.call;
- if (!call) return;
+ if (!result || !call) return;
+ const spokenFor = task.delegationId ?? null;
+ if (task.spokenFor === spokenFor) return;
+ task.spokenFor = spokenFor;
+ const characters = [...result].slice(0, 8_000);
+ const delegationId = task.delegationId;
for (let offset = 0; offset < characters.length; offset += 120) {
- call.send({
- type: "delegation.context.append",
- delegation_item_id: item.id,
- channel: "speakable",
- content: [{ type: "input_text", text: characters.slice(offset, offset + 120).join("") }],
- });
+ const chunk = characters.slice(offset, offset + 120).join("");
+ call.send(
+ delegationId
+ ? {
+ type: "delegation.context.append",
+ delegation_item_id: delegationId,
+ channel: "speakable",
+ content: [{ type: "input_text", text: chunk }],
+ }
+ : {
+ type: "session.context.append",
+ content: [{ type: "input_text", text: chunk }],
+ },
+ );
}
}
}
diff --git a/packages/host-runtime/test/app-server-host.external-voice.test.ts b/packages/host-runtime/test/app-server-host.external-voice.test.ts
index db360bd31..2c738e287 100644
--- a/packages/host-runtime/test/app-server-host.external-voice.test.ts
+++ b/packages/host-runtime/test/app-server-host.external-voice.test.ts
@@ -1,6 +1,6 @@
import { FakeHarnessAdapter } from "@codexhost/harness-adapter/testing";
import { harnessIdSchema, type JsonObject } from "@codexhost/shared-contracts";
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it, vi, type Mock } from "vitest";
import type { ExternalVoiceCallFactory } from "../src/external-voice-gateway.js";
import {
createFixture,
@@ -14,7 +14,7 @@ function transport() {
let input: Parameters[0];
const sent: JsonObject[] = [];
const close = vi.fn(async () => {});
- const createCall: ExternalVoiceCallFactory = vi.fn(async (options) => {
+ const createCall: Mock = vi.fn(async (options) => {
input = options;
return {
sdp: "v=0\r\nanswer",
@@ -70,6 +70,8 @@ describe("external native voice routing", () => {
type: "delegation",
target: "client",
id: "task-one",
+ handoff_id: "handoff-one",
+ user_bidi_turn_id: "user-one",
content: [{ type: "input_text", text: "Three plus two" }],
},
};
@@ -92,6 +94,18 @@ describe("external native voice routing", () => {
channel: "speakable",
content: [{ type: "input_text", text: "Five" }],
});
+ call.event({
+ type: "turn.done",
+ turn: { id: "user-one", role: "user", transcript: "Three plus two" },
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(
+ fixture.collector.messages.filter(
+ (message) =>
+ message.method === "turn/started" &&
+ (message.params as JsonObject)?.threadId === threadId,
+ ),
+ ).toHaveLength(1);
expect(adapter.sessions).toHaveLength(1);
expect(fixture.official.stdin.readableLength).toBe(0);
writeRequest(fixture.desktopInput, {
@@ -107,6 +121,163 @@ describe("external native voice routing", () => {
},
);
+ it("still projects command and turn completion after hangup, and late interrupt is a no-op", async () => {
+ const adapter = new FakeHarnessAdapter(harnessIdSchema.parse("grok"));
+ const call = transport();
+ const fixture = createFixture({
+ externalAdapters: new Map([["grok", adapter]]),
+ createExternalVoiceCall: call.createCall,
+ });
+ try {
+ const threadId = await startExternalThread(fixture, "codexhost/grok-native");
+ writeRequest(fixture.desktopInput, {
+ id: 2,
+ method: "thread/realtime/start",
+ params: {
+ threadId,
+ outputModality: "audio",
+ transport: { type: "webrtc", sdp: "v=0\r\noffer" },
+ },
+ });
+ await fixture.collector.waitFor((message) => requestId(message, 2));
+ call.event({
+ type: "turn.done",
+ turn: { role: "user", transcript: "list files" },
+ });
+ const session = adapter.sessions[0];
+ if (!session) throw new Error("Expected selected Harness session");
+ await fixture.collector.waitFor(
+ (message) =>
+ message.method === "turn/started" &&
+ (message.params as JsonObject)?.threadId === threadId,
+ );
+ const turnStarted = fixture.collector.messages.find(
+ (message) =>
+ message.method === "turn/started" &&
+ (message.params as JsonObject)?.threadId === threadId,
+ );
+ const turnId = ((turnStarted?.params as JsonObject)?.turn as JsonObject | undefined)?.id;
+ if (typeof turnId !== "string") throw new Error("Expected voice Turn id");
+ const commandId = session.startCommandExecution("ls -la");
+ await fixture.collector.waitFor(
+ (message) =>
+ message.method === "item/started" &&
+ ((message.params as JsonObject).item as JsonObject | undefined)?.type ===
+ "commandExecution",
+ );
+ session.completeItem(commandId, { status: "succeeded" });
+ await fixture.collector.waitFor(
+ (message) =>
+ message.method === "item/completed" &&
+ ((message.params as JsonObject).item as JsonObject | undefined)?.type ===
+ "commandExecution",
+ );
+ writeRequest(fixture.desktopInput, {
+ id: 3,
+ method: "thread/realtime/stop",
+ params: { threadId },
+ });
+ await fixture.collector.waitFor((message) => requestId(message, 3));
+ session.appendText("Only empty outputs/ and work/.");
+ session.succeedTurn();
+ const completed = await fixture.collector.waitFor(
+ (message) =>
+ message.method === "turn/completed" &&
+ (message.params as JsonObject)?.threadId === threadId,
+ );
+ expect(completed).toMatchObject({ params: { turn: { status: "completed" } } });
+ expect(
+ fixture.collector.messages.some(
+ (message) =>
+ message.method === "item/completed" &&
+ ((message.params as JsonObject).item as JsonObject | undefined)?.type ===
+ "commandExecution",
+ ),
+ ).toBe(true);
+ writeRequest(fixture.desktopInput, {
+ id: 4,
+ method: "turn/interrupt",
+ params: { threadId, turnId },
+ });
+ await expect(fixture.collector.waitFor((message) => requestId(message, 4))).resolves.toEqual({
+ id: 4,
+ result: {},
+ });
+ } finally {
+ await stopFixture(fixture);
+ }
+ });
+
+ it("seeds a reopened call with prior Thread messages and routes a user transcript without delegation", async () => {
+ const adapter = new FakeHarnessAdapter(harnessIdSchema.parse("grok"));
+ const call = transport();
+ const fixture = createFixture({
+ externalAdapters: new Map([["grok", adapter]]),
+ createExternalVoiceCall: call.createCall,
+ });
+ try {
+ const threadId = await startExternalThread(fixture, "codexhost/grok-native");
+ writeRequest(fixture.desktopInput, {
+ id: 2,
+ method: "turn/start",
+ params: { threadId, input: [{ type: "text", text: "list files" }] },
+ });
+ await fixture.collector.waitFor((message) => requestId(message, 2));
+ const first = adapter.sessions[0];
+ if (!first) throw new Error("Expected selected Harness session");
+ first.appendText("Only empty outputs/ and work/.");
+ first.succeedTurn();
+ await fixture.collector.waitFor(
+ (message) =>
+ message.method === "turn/completed" &&
+ (message.params as JsonObject)?.threadId === threadId,
+ );
+ writeRequest(fixture.desktopInput, {
+ id: 3,
+ method: "thread/realtime/start",
+ params: {
+ threadId,
+ outputModality: "audio",
+ transport: { type: "webrtc", sdp: "v=0\r\noffer" },
+ },
+ });
+ await fixture.collector.waitFor((message) => requestId(message, 3));
+ expect(call.createCall).toHaveBeenCalledWith(
+ expect.objectContaining({
+ input: expect.arrayContaining([
+ expect.objectContaining({
+ role: "assistant",
+ content: [expect.objectContaining({ text: "Only empty outputs/ and work/." })],
+ }),
+ ]),
+ }),
+ );
+ const startedBefore = fixture.collector.messages.filter(
+ (message) => message.method === "turn/started",
+ ).length;
+ call.event({
+ type: "turn.done",
+ turn: { role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await fixture.collector.waitFor(
+ (message) =>
+ message.method === "turn/started" &&
+ (message.params as JsonObject)?.threadId === threadId &&
+ fixture.collector.messages.filter((entry) => entry.method === "turn/started").length >
+ startedBefore,
+ );
+ const second = adapter.sessions[0];
+ if (!second) throw new Error("Expected follow-up Harness session");
+ second.appendText("The workspace still has only empty outputs/ and work/.");
+ second.succeedTurn();
+ await vi.waitFor(() =>
+ expect(call.sent.some((event) => event.type === "session.context.append")).toBe(true),
+ );
+ } finally {
+ await stopFixture(fixture);
+ }
+ });
+
it("closes a failed call and permits an explicit retry", async () => {
const call = transport();
const fixture = createFixture({ createExternalVoiceCall: call.createCall });
diff --git a/packages/host-runtime/test/app-server-host.projection-4.test.ts b/packages/host-runtime/test/app-server-host.projection-4.test.ts
index 9001eb796..e841bb0f3 100644
--- a/packages/host-runtime/test/app-server-host.projection-4.test.ts
+++ b/packages/host-runtime/test/app-server-host.projection-4.test.ts
@@ -1175,7 +1175,68 @@ describe("AppServerHost HarnessAdapter projection", () => {
await stopFixture(fixture);
});
- it("rejects an interrupt that does not reference the active Pi Turn", async () => {
+ it("treats interrupt of an already-completed Turn as a no-op success", async () => {
+ const fixture = createFixture();
+ const officialWrite = vi.fn();
+ fixture.official.stdin.on("data", officialWrite);
+ const threadId = await startPiThread(fixture);
+ const turnId = await startPiTurn(fixture, threadId);
+ const session = fixture.adapter.sessions[0];
+ if (!session) throw new Error("Fake Pi Session was not opened");
+ session.appendText("done");
+ session.succeedTurn();
+ await fixture.collector.waitFor((message) => method(message, "turn/completed"));
+
+ writeRequest(fixture.desktopInput, {
+ id: 3,
+ method: "turn/interrupt",
+ params: { threadId, turnId },
+ });
+ await expect(fixture.collector.waitFor((message) => requestId(message, 3))).resolves.toEqual({
+ id: 3,
+ result: {},
+ });
+ expect(officialWrite).not.toHaveBeenCalled();
+ await stopFixture(fixture);
+ });
+
+ it("does not cancel a newer Turn when interrupt names an earlier Turn", async () => {
+ const fixture = createFixture();
+ const threadId = await startPiThread(fixture);
+ const firstTurnId = await startPiTurn(fixture, threadId);
+ const first = fixture.adapter.sessions[0];
+ if (!first) throw new Error("Fake Pi Session was not opened");
+ first.appendText("first");
+ first.succeedTurn();
+ await fixture.collector.waitFor((message) => method(message, "turn/completed"));
+
+ const secondTurnId = await startPiTurn(fixture, threadId, 3);
+ const second = fixture.adapter.sessions[0];
+ if (!second) throw new Error("Fake Pi Session was not opened");
+ second.startCommandExecution("sleep 10");
+ const execute = vi.spyOn(second, "execute");
+
+ writeRequest(fixture.desktopInput, {
+ id: 4,
+ method: "turn/interrupt",
+ params: { threadId, turnId: firstTurnId },
+ });
+ await expect(fixture.collector.waitFor((message) => requestId(message, 4))).resolves.toEqual({
+ id: 4,
+ result: {},
+ });
+ expect(execute).not.toHaveBeenCalled();
+ expect(
+ fixture.collector.messages.some(
+ (message) =>
+ method(message, "turn/completed") &&
+ ((message.params as JsonObject).turn as JsonObject | undefined)?.id === secondTurnId,
+ ),
+ ).toBe(false);
+ await stopFixture(fixture);
+ });
+
+ it("accepts interrupt without an active Turn as already stopped", async () => {
const fixture = createFixture();
const officialWrite = vi.fn();
fixture.official.stdin.on("data", officialWrite);
@@ -1186,10 +1247,9 @@ describe("AppServerHost HarnessAdapter projection", () => {
method: "turn/interrupt",
params: { threadId, turnId: "missing-turn" },
});
- await expect(
- fixture.collector.waitFor((message) => requestId(message, 2)),
- ).resolves.toMatchObject({
- error: { code: -32074, message: "External turn/interrupt must reference the active Turn" },
+ await expect(fixture.collector.waitFor((message) => requestId(message, 2))).resolves.toEqual({
+ id: 2,
+ result: {},
});
expect(officialWrite).not.toHaveBeenCalled();
await stopFixture(fixture);
diff --git a/packages/host-runtime/test/external-voice-gateway.test.ts b/packages/host-runtime/test/external-voice-gateway.test.ts
index e5f965085..bb7ae3324 100644
--- a/packages/host-runtime/test/external-voice-gateway.test.ts
+++ b/packages/host-runtime/test/external-voice-gateway.test.ts
@@ -112,6 +112,8 @@ describe("configured voice gateway", () => {
delegation: { type: "client", ack_filler: false },
audio: { output: { voice: "cove" } },
});
+ expect(JSON.parse(String(form.get("session")))).not.toHaveProperty("input");
+ expect(JSON.parse(String(form.get("session")))).not.toHaveProperty("initial_items");
await call.connect();
expect(connections).toHaveLength(1);
expect(connections[0]).toMatchObject({
@@ -143,6 +145,32 @@ describe("configured voice gateway", () => {
expect(request).not.toHaveBeenCalled();
});
+ it("includes resumed conversation history in the native session create request", async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce(created())
+ .mockResolvedValueOnce(new Response());
+ vi.stubGlobal("fetch", request);
+ const factory = await gateway();
+ const history = [
+ {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "list files" }],
+ },
+ {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Only empty outputs/ and work/." }],
+ },
+ ];
+ await factory({ ...input(), input: history });
+ const form = request.mock.calls[0]?.[1]?.body as FormData;
+ const session = JSON.parse(String(form.get("session")));
+ expect(session.initial_items).toEqual(history);
+ expect(session).not.toHaveProperty("input");
+ });
+
it("never substitutes another provider or OAuth when the selected credential is missing", async () => {
const request = vi.fn();
vi.stubGlobal("fetch", request);
diff --git a/packages/host-runtime/test/external-voice-lifecycle.test.ts b/packages/host-runtime/test/external-voice-lifecycle.test.ts
index 04af1a698..4e9c3d141 100644
--- a/packages/host-runtime/test/external-voice-lifecycle.test.ts
+++ b/packages/host-runtime/test/external-voice-lifecycle.test.ts
@@ -4,7 +4,70 @@ import type { ExternalVoiceCall } from "../src/external-voice-gateway.js";
const params = { outputModality: "audio", transport: { type: "webrtc", sdp: "v=0\r\noffer" } };
+async function voiceSession() {
+ let event: Parameters<
+ ConstructorParameters[0]["createCall"]
+ >[0]["onEvent"] = () => {};
+ const send = vi.fn();
+ const task = vi.fn();
+ const voice = new ExternalVoice({
+ createCall: async (input) => {
+ event = input.onEvent;
+ return { sdp: "answer", connect: async () => {}, send, close: async () => {} };
+ },
+ runTask: task,
+ notify: async () => {},
+ diagnose: vi.fn(),
+ });
+ await voice.start("external", params);
+ return { event, send, task, voice };
+}
+
describe("voice lifecycle", () => {
+ it("answers a native handoff before turn.done and deduplicates its later transcript", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("Two empty folders.");
+ session.event({
+ type: "turn.created",
+ turn: { id: "u-native", role: "user", transcript: "What" },
+ });
+ const handoff = {
+ type: "delegation.created",
+ item: {
+ id: "native-handle",
+ type: "delegation",
+ target: "client",
+ handoff_id: "handoff-native",
+ user_bidi_turn_id: "u-native",
+ content: [{ type: "input_text", text: "What files did you just find?" }],
+ },
+ };
+ session.event(handoff);
+ session.event(handoff);
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ expect(session.task).toHaveBeenCalledWith(
+ "external",
+ "What files did you just find?",
+ expect.any(AbortSignal),
+ );
+ await vi.waitFor(() =>
+ expect(session.send).toHaveBeenCalledWith({
+ type: "delegation.context.append",
+ delegation_item_id: "native-handle",
+ channel: "speakable",
+ content: [{ type: "input_text", text: "Two empty folders." }],
+ }),
+ );
+ session.event({
+ type: "turn.done",
+ turn: { id: "u-native", role: "user", transcript: "What files did you just find?" },
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(session.task).toHaveBeenCalledTimes(1);
+ expect(session.send).toHaveBeenCalledTimes(1);
+ await session.voice.close();
+ });
+
it.each(["thread/realtime/started", "thread/realtime/sdp"])(
"does not resume startup after hangup while publishing %s",
async (blockedMethod) => {
@@ -61,6 +124,304 @@ describe("voice lifecycle", () => {
expect(voice.has("external")).toBe(false);
});
+ it("starts a Harness task from a user transcript when the speech model does not delegate", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("Only empty outputs/ and work/.");
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ expect(session.task).toHaveBeenCalledWith(
+ "external",
+ "你啱啱搵到咩檔案",
+ expect.any(AbortSignal),
+ );
+ await vi.waitFor(() => expect(session.send).toHaveBeenCalled());
+ expect(session.send).toHaveBeenCalledWith({
+ type: "session.context.append",
+ content: [{ type: "input_text", text: "Only empty outputs/ and work/." }],
+ });
+ });
+
+ it("starts a new Harness turn when the same question arrives on a distinct user turn", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("ok");
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ session.event({
+ type: "turn.done",
+ turn: { id: "u2", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(2));
+ });
+
+ it("starts distinct rapid user turns without waiting for a quiet window", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("ok");
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ session.event({
+ type: "turn.done",
+ turn: { id: "u2", role: "user", transcript: "你啱啱個任務搵到啲咩" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(2));
+ expect(session.task).toHaveBeenNthCalledWith(
+ 1,
+ "external",
+ "你啱啱搵到咩檔案",
+ expect.any(AbortSignal),
+ );
+ expect(session.task).toHaveBeenNthCalledWith(
+ 2,
+ "external",
+ "你啱啱個任務搵到啲咩",
+ expect.any(AbortSignal),
+ );
+ });
+
+ it("keeps one Harness turn and appends session context when a paraphrased delegation has no turn id", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("Only empty outputs/ and work/.");
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ session.event({
+ type: "delegation.created",
+ item: {
+ id: "task",
+ type: "delegation",
+ target: "client",
+ content: [{ type: "input_text", text: "Summarize the files found by the previous task." }],
+ },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ expect(session.task).toHaveBeenCalledWith(
+ "external",
+ "你啱啱搵到咩檔案",
+ expect.any(AbortSignal),
+ );
+ await vi.waitFor(() => expect(session.send).toHaveBeenCalled());
+ expect(session.send).toHaveBeenCalledWith({
+ type: "session.context.append",
+ content: [{ type: "input_text", text: "Only empty outputs/ and work/." }],
+ });
+ expect(session.send).not.toHaveBeenCalledWith(
+ expect.objectContaining({ delegation_item_id: "task" }),
+ );
+ });
+
+ it("attaches a late delegation that names the user turn without a second Harness turn", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("Only empty outputs/ and work/.");
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ await vi.waitFor(() => expect(session.send).toHaveBeenCalled());
+ session.event({
+ type: "delegation.created",
+ item: {
+ id: "late",
+ type: "delegation",
+ target: "client",
+ turn_id: "u1",
+ content: [{ type: "input_text", text: "Summarize the files found by the previous task." }],
+ },
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(session.task).toHaveBeenCalledTimes(1);
+ expect(session.send).toHaveBeenCalledWith({
+ type: "delegation.context.append",
+ delegation_item_id: "late",
+ channel: "speakable",
+ content: [{ type: "input_text", text: "Only empty outputs/ and work/." }],
+ });
+ });
+
+ it("runs a linked handoff before the final transcript and deduplicates that transcript", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("Only empty outputs/ and work/.");
+ session.event({
+ type: "delegation.created",
+ item: {
+ id: "early",
+ type: "delegation",
+ target: "client",
+ turn_id: "u1",
+ content: [{ type: "input_text", text: "你啱啱搵到咩檔案" }],
+ },
+ });
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ expect(session.task).toHaveBeenCalledWith(
+ "external",
+ "你啱啱搵到咩檔案",
+ expect.any(AbortSignal),
+ );
+ await vi.waitFor(() => expect(session.send).toHaveBeenCalled());
+ expect(session.send).toHaveBeenCalledWith({
+ type: "delegation.context.append",
+ delegation_item_id: "early",
+ channel: "speakable",
+ content: [{ type: "input_text", text: "Only empty outputs/ and work/." }],
+ });
+ });
+
+ it("appends context for a no-id delegation path after one user transcript", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("Only empty outputs/ and work/.");
+ session.event({
+ type: "delegation.created",
+ item: {
+ id: "early-noid",
+ type: "delegation",
+ target: "client",
+ content: [{ type: "input_text", text: "Summarize the files found by the previous task." }],
+ },
+ });
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ await vi.waitFor(() => expect(session.send).toHaveBeenCalled());
+ expect(session.send).toHaveBeenCalledWith({
+ type: "session.context.append",
+ content: [{ type: "input_text", text: "Only empty outputs/ and work/." }],
+ });
+ expect(session.send).not.toHaveBeenCalledWith(
+ expect.objectContaining({ delegation_item_id: "early-noid" }),
+ );
+ });
+
+ it("does not run a late unlinked delegation after two user utterances", async () => {
+ const session = await voiceSession();
+ session.task.mockImplementation(async (_thread, text) => `answer:${text}`);
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ session.event({
+ type: "turn.done",
+ turn: { id: "u2", role: "user", transcript: "你啱啱個任務搵到啲咩" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(2));
+ await vi.waitFor(() => expect(session.send).toHaveBeenCalledTimes(2));
+ session.event({
+ type: "delegation.created",
+ item: {
+ id: "late-unlinked",
+ type: "delegation",
+ target: "client",
+ content: [{ type: "input_text", text: "Summarize the files found by the previous task." }],
+ },
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(session.task).toHaveBeenCalledTimes(2);
+ expect(session.send).not.toHaveBeenCalledWith(
+ expect.objectContaining({ delegation_item_id: "late-unlinked" }),
+ );
+ });
+
+ it("does not bind an unrelated delegation to an old completed unmatched turn", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("Only empty outputs/ and work/.");
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ await vi.waitFor(() => expect(session.send).toHaveBeenCalled());
+ session.event({
+ type: "delegation.created",
+ item: {
+ id: "unrelated",
+ type: "delegation",
+ target: "client",
+ content: [{ type: "input_text", text: "Start a new investigation." }],
+ },
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(session.task).toHaveBeenCalledTimes(1);
+ expect(session.send).not.toHaveBeenCalledWith(
+ expect.objectContaining({ delegation_item_id: "unrelated" }),
+ );
+ });
+
+ it("does not bind a late no-id handle to a later unrelated user transcript", async () => {
+ const session = await voiceSession();
+ session.task
+ .mockResolvedValueOnce("Only empty outputs/ and work/.")
+ .mockResolvedValueOnce("The passphrase is still blue moon.");
+ session.event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "你啱啱搵到咩檔案" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(1));
+ await vi.waitFor(() => expect(session.send).toHaveBeenCalled());
+ session.event({
+ type: "delegation.created",
+ item: {
+ id: "old-handle",
+ type: "delegation",
+ target: "client",
+ content: [{ type: "input_text", text: "Summarize the files found by the previous task." }],
+ },
+ });
+ session.event({
+ type: "turn.done",
+ turn: { id: "u2", role: "user", transcript: "藍色月亮係咩" },
+ });
+ await vi.waitFor(() => expect(session.task).toHaveBeenCalledTimes(2));
+ await vi.waitFor(() =>
+ expect(session.send).toHaveBeenCalledWith({
+ type: "session.context.append",
+ content: [{ type: "input_text", text: "The passphrase is still blue moon." }],
+ }),
+ );
+ expect(session.send).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ delegation_item_id: "old-handle",
+ content: [{ type: "input_text", text: "The passphrase is still blue moon." }],
+ }),
+ );
+ });
+
+ it("passes resumed Thread messages into call creation", async () => {
+ const createCall = vi.fn(async () => ({
+ sdp: "answer",
+ connect: async () => {},
+ send: () => {},
+ close: async () => {},
+ }));
+ const voice = new ExternalVoice({
+ createCall,
+ runTask: vi.fn(),
+ notify: async () => {},
+ diagnose: vi.fn(),
+ });
+ const conversation = [
+ {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Only empty outputs/ and work/." }],
+ },
+ ];
+ await voice.start("external", params, conversation);
+ expect(createCall).toHaveBeenCalledWith(expect.objectContaining({ input: conversation }));
+ });
+
it("suppresses a late agent answer after hangup without cancelling its work", async () => {
const answer = Promise.withResolvers();
let event: Parameters<
@@ -80,6 +441,21 @@ describe("voice lifecycle", () => {
});
await voice.start("external", params);
event({
+ type: "turn.done",
+ turn: { id: "u1", role: "user", transcript: "request" },
+ });
+ await vi.waitFor(() => expect(task).toHaveBeenCalledTimes(1));
+ await voice.stop("external");
+ answer.resolve("old result");
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(send).not.toHaveBeenCalled();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not start Harness work from delegation when no user transcript arrives", async () => {
+ const session = await voiceSession();
+ session.task.mockResolvedValue("should not run");
+ session.event({
type: "delegation.created",
item: {
id: "task",
@@ -88,11 +464,8 @@ describe("voice lifecycle", () => {
content: [{ type: "input_text", text: "request" }],
},
});
- expect(task).toHaveBeenCalledTimes(1);
- await voice.stop("external");
- answer.resolve("old result");
- await new Promise((resolve) => setImmediate(resolve));
- expect(send).not.toHaveBeenCalled();
- expect(close).toHaveBeenCalledTimes(1);
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(session.task).not.toHaveBeenCalled();
+ expect(session.send).not.toHaveBeenCalled();
});
});
diff --git a/packages/host-runtime/test/external-voice-results.test.ts b/packages/host-runtime/test/external-voice-results.test.ts
new file mode 100644
index 000000000..222a3c67c
--- /dev/null
+++ b/packages/host-runtime/test/external-voice-results.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { hostItemIdSchema, hostTurnIdSchema } from "@codexhost/shared-contracts";
+import { ExternalVoiceResults, speakableFromProjectedTurn } from "../src/external-voice-results.js";
+
+const threadId = "thread-1";
+const turnId = hostTurnIdSchema.parse("11111111-1111-4111-8111-111111111111");
+const itemId = hostItemIdSchema.parse("22222222-2222-4222-8222-222222222222");
+
+describe("external voice results", () => {
+ it("speaks the inferred final answer when live item completion is missing", async () => {
+ const results = new ExternalVoiceResults();
+ const controller = new AbortController();
+ const pending = results.watch(threadId, turnId, controller.signal);
+ results.observe(
+ threadId,
+ { type: "turn.completed", turnId, outcome: { status: "succeeded" } },
+ {
+ items: [
+ { type: "commandExecution", command: "ls", status: "completed" },
+ {
+ type: "agentMessage",
+ text: "Workspace has empty outputs/ and work/.",
+ phase: "final_answer",
+ },
+ ],
+ },
+ );
+ await expect(pending).resolves.toBe("Workspace has empty outputs/ and work/.");
+ });
+
+ it("accumulates streamed agent text before turn completion", async () => {
+ const results = new ExternalVoiceResults();
+ const controller = new AbortController();
+ const pending = results.watch(threadId, turnId, controller.signal);
+ results.observe(threadId, {
+ type: "item.started",
+ turnId,
+ item: { type: "agentMessage", itemId, text: "" },
+ });
+ results.observe(threadId, {
+ type: "item.updated",
+ turnId,
+ itemId,
+ update: { type: "text.append", text: "The passphrase is blue moon." },
+ });
+ results.observe(threadId, {
+ type: "turn.completed",
+ turnId,
+ outcome: { status: "succeeded" },
+ });
+ await expect(pending).resolves.toBe("The passphrase is blue moon.");
+ });
+
+ it("prefers projected final_answer over commentary", () => {
+ expect(
+ speakableFromProjectedTurn({
+ items: [
+ { type: "agentMessage", text: "Checking files.", phase: "commentary" },
+ { type: "agentMessage", text: "Only empty directories.", phase: "final_answer" },
+ ],
+ }),
+ ).toBe("Only empty directories.");
+ });
+});
diff --git a/packages/host-runtime/test/external-voice-session-input.test.ts b/packages/host-runtime/test/external-voice-session-input.test.ts
new file mode 100644
index 000000000..051d4bf18
--- /dev/null
+++ b/packages/host-runtime/test/external-voice-session-input.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { voiceSessionInputFromTurns } from "../src/external-voice-session-input.js";
+
+describe("voice session input from Thread Turns", () => {
+ it("seeds user and agent messages and skips commentary and tools", () => {
+ expect(
+ voiceSessionInputFromTurns([
+ {
+ items: [
+ {
+ type: "userMessage",
+ content: [{ type: "text", text: "what files did you just find?" }],
+ },
+ { type: "commandExecution", command: "ls" },
+ { type: "agentMessage", text: "Checking.", phase: "commentary" },
+ {
+ type: "agentMessage",
+ text: "Only empty outputs/ and work/.",
+ phase: "final_answer",
+ },
+ ],
+ },
+ ]),
+ ).toEqual([
+ {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "what files did you just find?" }],
+ },
+ {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Only empty outputs/ and work/." }],
+ },
+ ]);
+ });
+
+ it("returns no input for a Thread with no speakable history", () => {
+ expect(voiceSessionInputFromTurns([])).toEqual([]);
+ expect(voiceSessionInputFromTurns([{ items: [{ type: "commandExecution" }] }])).toEqual([]);
+ });
+});
diff --git a/packages/renderer-extension/src/renderer-overlay-thread-isolation.ts b/packages/renderer-extension/src/renderer-overlay-thread-isolation.ts
index 0c6dc337e..c84616208 100644
--- a/packages/renderer-extension/src/renderer-overlay-thread-isolation.ts
+++ b/packages/renderer-extension/src/renderer-overlay-thread-isolation.ts
@@ -19,12 +19,12 @@ export function isAvatarOverlayHref(href: string): boolean {
export function overlayBlocksHostNotification(
method: string,
threadId: string | undefined,
- activeRealtimeThreadIds: ReadonlySet,
+ allowedRealtimeThreadIds: ReadonlySet,
): boolean {
if (method.startsWith("thread/realtime/")) {
return false;
}
- if (threadId !== undefined && activeRealtimeThreadIds.has(threadId)) {
+ if (threadId !== undefined && allowedRealtimeThreadIds.has(threadId)) {
return false;
}
if (BLOCKED_METHODS.has(method)) return true;
@@ -82,26 +82,22 @@ export function installOverlayThreadIsolation(
if (typeof href !== "string" || !isAvatarOverlayHref(href)) {
return { status: "disabled", dispose() {} };
}
- const activeRealtimeThreadIds = new Set();
+ // Native stream ownership can outlive the call. A primary follower still
+ // receives this Thread's updates through the overlay owner after hangup.
+ const voiceThreadIds = new Set();
const listener = (event: OverlayIsolationMessageEvent): void => {
const message = event.data;
if (!isRecord(message) || message.type !== "mcp-notification") return;
if (typeof message.method !== "string") return;
const threadId = notificationThreadId(message.params);
if (message.method === "thread/realtime/started" && threadId) {
- activeRealtimeThreadIds.add(threadId);
+ voiceThreadIds.add(threadId);
return;
}
- if (
- (message.method === "thread/realtime/closed" || message.method === "thread/realtime/error") &&
- threadId
- ) {
- activeRealtimeThreadIds.delete(threadId);
- return;
- }
- if (overlayBlocksHostNotification(message.method, threadId, activeRealtimeThreadIds)) {
+ if (overlayBlocksHostNotification(message.method, threadId, voiceThreadIds)) {
event.stopImmediatePropagation();
}
+ if (message.method === "thread/deleted" && threadId) voiceThreadIds.delete(threadId);
};
target.addEventListener("message", listener, true);
return {
diff --git a/packages/renderer-extension/test/renderer-overlay-thread-isolation.test.ts b/packages/renderer-extension/test/renderer-overlay-thread-isolation.test.ts
index b7c2180c8..c76ab5937 100644
--- a/packages/renderer-extension/test/renderer-overlay-thread-isolation.test.ts
+++ b/packages/renderer-extension/test/renderer-overlay-thread-isolation.test.ts
@@ -57,6 +57,31 @@ class FakeOverlayWindow implements OverlayIsolationTarget {
const overlayHref = "app://-/index.html?initialRoute=%2Favatar-overlay";
describe("overlay thread isolation", () => {
+ it("keeps delivering later text turns for a voice Thread after hangup", () => {
+ const overlay = new FakeOverlayWindow({ href: overlayHref });
+ const hook = installOverlayThreadIsolation(overlay);
+ const dispatch = (method: string, params: Record) =>
+ overlay.dispatch({ type: "mcp-notification", method, params });
+ dispatch("thread/realtime/started", { threadId: "known-thread" });
+ dispatch("turn/started", { threadId: "known-thread", turn: { id: "voice-turn" } });
+ dispatch("thread/realtime/closed", { threadId: "known-thread" });
+ dispatch("turn/completed", { threadId: "known-thread", turn: { id: "voice-turn" } });
+ dispatch("thread/realtime/started", { threadId: "another-voice-thread" });
+ expect(
+ dispatch("turn/started", { threadId: "known-thread", turn: { id: "later-text-turn" } }),
+ ).toBe(false);
+ expect(
+ dispatch("item/completed", { threadId: "known-thread", turnId: "later-text-turn" }),
+ ).toBe(false);
+ expect(
+ dispatch("turn/completed", { threadId: "known-thread", turn: { id: "later-text-turn" } }),
+ ).toBe(false);
+ expect(dispatch("turn/started", { threadId: "unknown-cli-thread" })).toBe(true);
+ dispatch("thread/deleted", { threadId: "known-thread" });
+ expect(dispatch("turn/started", { threadId: "known-thread" })).toBe(true);
+ hook.dispose();
+ });
+
it("recognizes avatar overlay hrefs", () => {
expect(isAvatarOverlayHref(overlayHref)).toBe(true);
expect(isAvatarOverlayHref("app://-/avatar-overlay.html")).toBe(true);
@@ -137,6 +162,75 @@ describe("overlay thread isolation", () => {
method: "item/completed",
params: { threadId: "voice-thread" },
}),
+ ).toBe(false);
+ hook.dispose();
+ });
+
+ it("keeps the overlay open for an in-flight Turn after hangup until it completes", () => {
+ const overlay = new FakeOverlayWindow({ href: overlayHref });
+ const hook = installOverlayThreadIsolation(overlay);
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "thread/realtime/started",
+ params: { threadId: "voice-thread" },
+ });
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "turn/started",
+ params: { threadId: "voice-thread", turn: { id: "turn-3" } },
+ });
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "item/started",
+ params: { threadId: "voice-thread", turnId: "turn-3" },
+ });
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "thread/realtime/closed",
+ params: { threadId: "voice-thread" },
+ });
+ expect(
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "item/completed",
+ params: { threadId: "voice-thread", turnId: "turn-3" },
+ }),
+ ).toBe(false);
+ expect(
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "turn/completed",
+ params: { threadId: "voice-thread", turn: { id: "turn-3" } },
+ }),
+ ).toBe(false);
+ expect(
+ overlay.dispatch({
+ type: "mcp-notification",
+ method: "item/completed",
+ params: { threadId: "voice-thread", turnId: "turn-3" },
+ }),
+ ).toBe(false);
+ hook.dispose();
+ });
+
+ it("delivers final Turn and Item events when idle arrives before completion after hangup", () => {
+ const overlay = new FakeOverlayWindow({ href: overlayHref });
+ const hook = installOverlayThreadIsolation(overlay);
+ const dispatch = (method: string, params: Record) =>
+ overlay.dispatch({ type: "mcp-notification", method, params });
+ dispatch("thread/realtime/started", { threadId: "voice-thread" });
+ dispatch("turn/started", { threadId: "voice-thread", turn: { id: "native-turn" } });
+ dispatch("thread/realtime/closed", { threadId: "voice-thread" });
+ dispatch("thread/status/changed", { threadId: "voice-thread", status: { type: "idle" } });
+
+ expect(dispatch("item/completed", { threadId: "voice-thread", turnId: "native-turn" })).toBe(
+ false,
+ );
+ expect(
+ dispatch("turn/completed", { threadId: "voice-thread", turn: { id: "native-turn" } }),
+ ).toBe(false);
+ expect(
+ dispatch("turn/started", { threadId: "unrelated-thread", turn: { id: "unrelated-turn" } }),
).toBe(true);
hook.dispose();
});
From bcca7711d204700ad3fc368d724de453eef868c7 Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Mon, 19 Oct 2026 01:17:34 +0900
Subject: [PATCH 28/98] chore: prepare v0.13.3-4
---
Cargo.lock | 10 +++++-----
Cargo.toml | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
4 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index f1adee865..61f9f7190 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "codexhost-gate-a-native"
-version = "0.13.3-3"
+version = "0.13.3-4"
dependencies = [
"codexhost-platform",
"codexhost-shim",
@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "codexhost-launcher"
-version = "0.13.3-3"
+version = "0.13.3-4"
dependencies = [
"codexhost-platform",
"fs2",
@@ -106,7 +106,7 @@ dependencies = [
[[package]]
name = "codexhost-platform"
-version = "0.13.3-3"
+version = "0.13.3-4"
dependencies = [
"libproc",
"nix",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "codexhost-shim"
-version = "0.13.3-3"
+version = "0.13.3-4"
dependencies = [
"codexhost-platform",
"fs2",
@@ -131,7 +131,7 @@ dependencies = [
[[package]]
name = "codexhost-updater"
-version = "0.13.3-3"
+version = "0.13.3-4"
dependencies = [
"codexhost-platform",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index c400377dd..a8c17872a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ default-members = [
resolver = "3"
[workspace.package]
-version = "0.13.3-3"
+version = "0.13.3-4"
edition = "2024"
rust-version = "1.97.1"
publish = false
diff --git a/package-lock.json b/package-lock.json
index cb4fa82ff..9107091c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexhost",
- "version": "0.13.3-3",
+ "version": "0.13.3-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexhost",
- "version": "0.13.3-3",
+ "version": "0.13.3-4",
"license": "LGPL-3.0-only",
"workspaces": [
"packages/*",
diff --git a/package.json b/package.json
index 89fe147da..0f602af93 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexhost",
- "version": "0.13.3-3",
+ "version": "0.13.3-4",
"private": true,
"license": "LGPL-3.0-only",
"type": "module",
From 9fdf2c8d17bd64a2d4ca1bfdfc410a2d6bf2da2b Mon Sep 17 00:00:00 2001
From: karl <142769090+karlorz@users.noreply.github.com>
Date: Mon, 19 Oct 2026 09:46:32 +0900
Subject: [PATCH 29/98] fix(voice): route native Desktop live-voice calls
through the configured gateway
Desktop 26.1013.40934 always creates its WebRTC call in the Renderer before
asking app-server to attach with transport existingCall, so the configured
experimental_realtime_* base URLs alone cannot route that bootstrap request.
The old Statsig gate is absent from this build.
- Renderer: wrap the audited native WebFetch singleton for POST
/wham/realtime/calls only, forward it over codexhost/realtime/call, and keep
every other request on its original receiver and arguments. Header
forwarding is allowlisted before the bridge, so Desktop OAuth,
ChatGPT-Account-Id and Cookie never cross it; the Host repeats the allowlist.
- Host: add NativeVoiceGateway, which reads the selected provider credential
from config.toml, validates matching HTTPS origins, and returns only status,
SDP body and allowed response headers. Ambiguous transport failures, timeouts
and cancellations never fall back to the signed-in ChatGPT account.
- Cancellation aborts an in-flight bootstrap; a known created call is hung up
when startup fails or its answer arrives after cancellation.
- External Harness Threads attach to the same existing call without a second
creation or SDP exchange, retaining the selected Harness for new-chat launches.
- Extract the shared gateway config reader and hangup helper, and the shared
call-id grammar, instead of duplicating them across the gateway paths.
- Document actual support, the account-failover limitation and its core seams.
Quota failover is not implemented: no public CPA plugin contract observes a
pre-call usage_limit_reached for this route. Disabled-account exclusion is
already enforced by CPA core before any scheduler plugin sees a candidate.
---
.../operations/cpa-live-voice-desktop-hook.md | 96 +++++++-
packages/host-runtime/src/app-server-host.ts | 19 ++
.../src/external-voice-gateway.ts | 154 ++++--------
packages/host-runtime/src/external-voice.ts | 25 +-
.../host-runtime/src/native-voice-gateway.ts | 126 ++++++++++
.../src/realtime-gateway-config.ts | 92 ++++++++
.../test/app-server-host-fixture.ts | 2 +-
.../test/external-voice-gateway.test.ts | 14 ++
.../test/external-voice-lifecycle.test.ts | 24 ++
.../test/native-voice-gateway.test.ts | 169 ++++++++++++++
.../src/cpa-live-voice-entry.ts | 2 +
.../src/production-entry.ts | 2 +
.../src/renderer-client-owned-voice.ts | 220 ++++++++++++++++++
.../src/renderer-cpa-live-voice-hook.ts | 21 +-
.../src/renderer-external-voice-launch.ts | 32 ++-
.../test/renderer-client-owned-voice.test.ts | 173 ++++++++++++++
.../renderer-external-voice-launch.test.ts | 17 ++
packages/shared-contracts/src/index.ts | 1 +
.../shared-contracts/src/realtime-call.ts | 71 ++++++
.../test/realtime-call.test.ts | 67 ++++++
20 files changed, 1184 insertions(+), 143 deletions(-)
create mode 100644 packages/host-runtime/src/native-voice-gateway.ts
create mode 100644 packages/host-runtime/src/realtime-gateway-config.ts
create mode 100644 packages/host-runtime/test/native-voice-gateway.test.ts
create mode 100644 packages/renderer-extension/src/renderer-client-owned-voice.ts
create mode 100644 packages/renderer-extension/test/renderer-client-owned-voice.test.ts
create mode 100644 packages/shared-contracts/src/realtime-call.ts
create mode 100644 packages/shared-contracts/test/realtime-call.test.ts
diff --git a/docs/operations/cpa-live-voice-desktop-hook.md b/docs/operations/cpa-live-voice-desktop-hook.md
index b86cd380e..e3cacbcae 100644
--- a/docs/operations/cpa-live-voice-desktop-hook.md
+++ b/docs/operations/cpa-live-voice-desktop-hook.md
@@ -1,6 +1,6 @@
# CPA live-voice Desktop hook
-Gated CodexHost Renderer hook that forces durable-only `clientOwnsCall` on official Codex Desktop **26.1007.30912**, so CPA can create the realtime call (`POST /v1/realtime/calls`) without rewriting `app.asar` or replacing `/Applications/ChatGPT.app`.
+CodexHost routes native live-voice call creation through the configured realtime gateway without rewriting `app.asar` or replacing `/Applications/ChatGPT.app`. Desktop **26.1007.30912** uses a call-ownership gate override. Desktop **26.1013.40934** uses the native client-owned-call flow described below.
## Default
@@ -13,7 +13,84 @@ experimental_realtime_ws_base_url = "https:///v1"
experimental_realtime_webrtc_call_base_url = "https:///v1"
```
-## What it changes
+## Desktop 26.1013.40934: client-owned calls
+
+This Desktop build always creates its WebRTC call in the Renderer before asking
+app-server to attach with `transport: { type: "existingCall", callId }`. The old
+Statsig gate is absent. The two `experimental_realtime_*_base_url` settings alone
+therefore cannot route that initial frontend request.
+
+On the exact audited `app-initial-90eb42395b7d.js` and
+`app-shared-52443cbe9ac4.js` assets, the hook wraps the loaded native WebFetch
+singleton's `fetch` method only for `POST /wham/realtime/calls`. It forwards the
+native SDP/session through `codexhost/realtime/call` on the local Host connection.
+Other WebFetch requests pass through with their original receiver and arguments.
+No global fetch, message transport or application archive is replaced.
+
+The Host reads the configured realtime URLs and selected provider's
+`experimental_bearer_token` or `env_key`. Both URLs must use HTTPS and share an
+origin. Header forwarding is allowlisted twice: the Renderer bridge drops every
+name outside `REALTIME_CALL_FORWARDED_HEADERS` before the request crosses to the
+Host, and the Host applies the same list again before sending. Desktop OAuth,
+`ChatGPT-Account-Id`, `Cookie` and every other unlisted header are therefore
+excluded at both boundaries. Credentials stay in the Host; only the SDP response,
+status and allowed response headers return to the Renderer. Configuration and
+transport failures do not fall back to the signed-in ChatGPT account. The
+Settings preference is checked again at call time.
+
+Cancellation aborts an in-flight bootstrap. A known created call is hung up if
+startup fails or its answer arrives after cancellation. If no call identity is
+available, remote cleanup cannot be confirmed. Existing native app-server
+sideband and stop requests continue through their configured gateway route.
+External Harness Threads attach to the same existing call without a second
+creation or SDP exchange, retaining the selected Harness for new-chat launches.
+
+The native frontend exports used here are version-specific and must be re-audited
+before another Desktop build is enabled. An enabled CPA scheduler only selects
+accounts for requests that actually reach CPA. Its configured inventory and
+selection counts do not establish successful upstream calls.
+
+### Account failover: current limitation
+
+Bounded quota failover is **not** implemented, because no public CPA plugin
+contract can observe a pre-call quota result for this route. Evidence from the
+deployed baseline:
+
+- `internal/client/codex/live/live.go:223` selects once via `selectOAuth`, and
+ `:330` sends that single selection once (`performRequest(selected)`). There is
+ no retry loop, no `tried`-set and no second `SelectAuthByKind` call on this
+ path.
+- The plugin-facing scheduler contract is pick-only: `SchedulerPickRequest` /
+ `SchedulerPickResponse` in `sdk/pluginapi/types.go:487-546` carry no outcome or
+ result field, and the only scheduler ABI method is `scheduler.pick`
+ (`sdk/pluginabi/types.go:55`). A scheduler plugin cannot learn that its pick
+ returned `usage_limit_reached`.
+- The result-feedback contracts that do exist — `RequestLifecyclePlugin`
+ (`HandleRequestComplete`), `RequestInterceptor` and `ResponseInterceptor` —
+ are emitted only by `BaseAPIHandler` execution paths
+ (`sdk/api/handlers/handlers_execution.go`, `handlers_stream.go`,
+ `handlers_interceptors.go:160-165`). The Codex live handler is a standalone
+ `Handler` (`internal/client/codex/live/live.go:49`) that never participates in
+ interception or lifecycle, so those callbacks never fire for `/v1/realtime/calls`.
+- The `QuotaProvider` contract (`sdk/pluginapi/types.go:1577`) reports and resets
+ quota for management clients; it is not wired into live selection and cannot
+ skip an exhausted account before a call.
+- Disabled-account exclusion is already enforced by core before any plugin sees a
+ candidate: `sdk/cliproxy/auth/conductor_selection.go:1852` drops
+ `candidate.Disabled`, and `availableAuthsForSelector` narrows the set the plugin
+ receives. `cpa-live-voice` intersects that set with `live_auth_ids`
+ (`selector.go` `PickAuth`), so the disabled Alice Plus credential cannot be
+ selected — no plugin change is required for exclusion.
+
+Consequently a retry that skips the exhausted account and re-selects a remaining
+priority needs a new CPA core seam: the live handler must record the definitive
+`usage_limit_reached` result and re-enter selection with that account excluded.
+That is a core change, not a plugin change, and is out of scope here pending a
+separate decision. The smallest core seam would be an exported
+"exclude these auth IDs and select again" entry point plus a single bounded retry
+in `Handle` after a definitive pre-call quota response.
+
+## Desktop 26.1007.30912: call-ownership gate
Desktop’s voice-start path sets `clientOwnsCall` when the `hostId` argument is `durable` **or** both of these hold: the host's app-server version supports the `threadRealtimeExistingCall` capability (a version comparison, not a stored flag), and Statsig gate `206132468` passes. That second branch POSTs to ChatGPT `/wham/realtime/calls` and then starts `thread/realtime/start` as `ExistingCall`. CPA never sees creation, so the sideband websocket 404s.
@@ -31,11 +108,17 @@ Verify before a trial: on the overlay, `__STATSIG__.firstInstance.checkGate("206
Overlay discovery, connection, and injection share one timeout. Overlay attempts run concurrently, and a stalled or disappearing overlay does not prevent the primary Host session from becoming ready after that deadline.
+The Controller sets Zod's global `jitless` configuration before evaluating either
+the primary Renderer or the overlay bundle. Response validation therefore runs
+without JavaScript string compilation under Desktop's Content Security Policy,
+including when the bundle was initially evaluated through the debugger. The
+policy does not need `unsafe-eval`.
+
The hook does not copy OpenAI frontend JavaScript into git. Tests cover the asset gate and Statsig override with synthetic clients.
## Version gate
-Audited only for Desktop **26.1007.30912** (primary bundle `app-initial-6350d04672d0.js`). Re-audit before enabling on a newer build. Fail closed: no wrap, no ASAR patch, keep `~/Applications/ChatGPT-Voice.app` if that copy is still the working path.
+Audited for Desktop **26.1007.30912** (gate path, primary bundle `app-initial-6350d04672d0.js`) and **26.1013.40934** (client-owned path, assets listed above). Re-audit before enabling on another build. Fail closed: no wrap, no ASAR patch, keep `~/Applications/ChatGPT-Voice.app` if that copy is still the working path.
## Invariants
@@ -49,8 +132,9 @@ Audited only for Desktop **26.1007.30912** (primary bundle `app-initial-6350d046
New-chat voice launches use the primary Composer's current Harness selection.
On the audited Desktop build, the primary Renderer imports the already-loaded
-browser module `app-shared-8bd9d0399038.js` and wraps only its native
-`realtimeVoiceRuntime` service reference (export `fN`). Its RPC stubs themselves
+browser module `app-shared-8bd9d0399038.js` (26.1007, export `fN`) or
+`app-shared-52443cbe9ac4.js` (26.1013, export `Kat`) and wraps only its native
+`realtimeVoiceRuntime` service reference. Its RPC stubs themselves
are immutable. For `composer_button_new_thread`, the wrapper snapshots the
matching Host's selected transport carrier into
`activeCollaborationMode.settings.model`. The separate voice overlay forwards
@@ -124,7 +208,7 @@ Turn, returns success without cancelling a newer Turn.
The speech service is instructed to delegate every question and task, including
simple arithmetic; it supplies speech rather than owning task reasoning.
The prototype defaults to Cantonese and the protocol's `cove` voice; supported V3
-voices can be requested. Text/audio append transports, ExistingCall attachment,
+voices can be requested. Text/audio append transports,
overlapping-task steering, newer public GPT-Live event dialects, remote-host media
topology, and seamless call recovery are not supported. A rejected control connection
or session-initialization timeout emits error/closed notifications and performs
diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts
index 2c5e63517..9e81f16fb 100644
--- a/packages/host-runtime/src/app-server-host.ts
+++ b/packages/host-runtime/src/app-server-host.ts
@@ -1,3 +1,5 @@
+import { REALTIME_CALL_METHOD } from "@codexhost/shared-contracts";
+import { NativeVoiceGateway } from "./native-voice-gateway.js";
import {
DELEGATION_READ_METHOD,
delegationReadParamsSchema,
@@ -620,6 +622,7 @@ export class AppServerHost {
readonly #executionHosts = new WeakMap();
readonly #externalPrewarms = new ExternalThreadPrewarms();
readonly #externalSteering = new ExternalTurnSteering();
+ readonly #nativeVoiceGateway: NativeVoiceGateway;
readonly #externalVoice: ExternalVoice;
readonly #externalVoiceResults = new ExternalVoiceResults();
readonly #liveCommandCache = new LiveCommandCatalogCache();
@@ -684,6 +687,7 @@ export class AppServerHost {
return this.#takeConsoleReply(value);
});
const environment = this.#options.environment ?? process.env;
+ this.#nativeVoiceGateway = new NativeVoiceGateway(environment);
this.#externalVoice = new ExternalVoice({
createCall: options.createExternalVoiceCall ?? configuredExternalVoiceGateway(environment),
notify: (method, params) => this.#writer.json({ method, params }),
@@ -868,6 +872,7 @@ export class AppServerHost {
close(): void {
if (this.#closeRequested) return;
this.#closeRequested = true;
+ this.#nativeVoiceGateway.close();
void this.#externalVoice.close();
this.#options.sharedThreads?.close();
this.#externalRuntime.idleRelease.disable();
@@ -1011,6 +1016,7 @@ export class AppServerHost {
return this.#closeRequested ? 0 : 1;
} finally {
this.#options.sharedThreads?.close();
+ this.#nativeVoiceGateway.close();
await this.#externalVoice.close();
this.#pluginLoadAbort.abort();
// Stop replacement waiters before waiting for their tracked Host operations.
@@ -1308,6 +1314,19 @@ export class AppServerHost {
);
return;
}
+ if (request.method === REALTIME_CALL_METHOD && !this.#options.externalOnly) {
+ try {
+ const result = await this.#nativeVoiceGateway.request(request.params);
+ await this.#writer.json(rpcEnvelope(request, { result: jsonValueSchema.parse(result) }));
+ } catch {
+ // The Renderer maps every voice failure to one message; never echo request
+ // payloads, schema details or transport diagnostics back over the bridge.
+ await this.#writer.json(
+ rpcError(request, -32090, "Configured voice gateway request failed"),
+ );
+ }
+ return;
+ }
if (request.method === DELEGATION_READ_METHOD) {
const parsed = delegationReadParamsSchema.safeParse(request.params);
if (!parsed.success) {
diff --git a/packages/host-runtime/src/external-voice-gateway.ts b/packages/host-runtime/src/external-voice-gateway.ts
index 9888bc384..07d5b9924 100644
--- a/packages/host-runtime/src/external-voice-gateway.ts
+++ b/packages/host-runtime/src/external-voice-gateway.ts
@@ -1,8 +1,10 @@
-import { readFile } from "node:fs/promises";
-import { homedir } from "node:os";
-import path from "node:path";
-import { parse } from "smol-toml";
import WebSocket from "ws";
+import {
+ hangupRealtimeCall,
+ readRealtimeGateway,
+ realtimeGatewayUrl,
+} from "./realtime-gateway-config.js";
+import { isRealtimeCallId, realtimeCallIdFromLocation } from "@codexhost/shared-contracts";
import type { JsonObject } from "@codexhost/protocol-core";
export interface ExternalVoiceCall {
@@ -15,6 +17,8 @@ export interface ExternalVoiceCall {
export type ExternalVoiceCallFactory = (input: {
sdp: string;
+ /** A Desktop-owned call already created through the configured gateway. */
+ callId?: string;
voice?: string;
signal: AbortSignal;
/** Prior Thread messages for GPT-Live `session.initial_items`; omitted when empty. */
@@ -32,96 +36,44 @@ export function configuredExternalVoiceGateway(
environment: NodeJS.ProcessEnv,
): ExternalVoiceCallFactory {
return async (input) => {
- let config: Record;
- try {
- config = parse(
- await readFile(
- path.join(environment.CODEX_HOME ?? path.join(homedir(), ".codex"), "config.toml"),
- "utf8",
- ),
- );
- } catch {
- throw new Error("External voice gateway configuration is unavailable");
- }
- const providers = config.model_providers;
- const provider =
- object(providers) && typeof config.model_provider === "string"
- ? providers[config.model_provider]
- : undefined;
- const token = object(provider)
- ? typeof provider.experimental_bearer_token === "string"
- ? provider.experimental_bearer_token
- : typeof provider.env_key === "string"
- ? environment[provider.env_key]
- : undefined
- : undefined;
- const base = config.experimental_realtime_webrtc_call_base_url;
- const sidebandBase = config.experimental_realtime_ws_base_url ?? base;
- if (typeof base !== "string" || typeof sidebandBase !== "string" || !token) {
- throw new Error("External voice requires a configured realtime gateway and API credential");
- }
- let url: URL;
- let sideband: URL;
- try {
- url = new URL(base);
- sideband = new URL(sidebandBase);
- } catch {
- throw new Error("External voice gateway URL is invalid");
- }
- if (
- url.protocol !== "https:" ||
- sideband.protocol !== "https:" ||
- url.origin !== sideband.origin ||
- url.username ||
- url.password ||
- sideband.username ||
- sideband.password ||
- url.search ||
- url.hash ||
- sideband.search ||
- sideband.hash
- )
- throw new Error("External voice requires matching HTTPS gateway origins");
+ const gateway = await readRealtimeGateway(environment);
+ const { sideband, token, model } = gateway;
const headers = { Authorization: `Bearer ${token}`, "OpenAI-Alpha": "quicksilver=v2" };
- const form = new FormData();
- form.append("sdp", input.sdp);
- form.append(
- "session",
- JSON.stringify({
- model:
- typeof config.experimental_realtime_ws_model === "string"
- ? config.experimental_realtime_ws_model
- : "gpt-realtime",
- instructions:
- "Use natural Hong Kong Cantonese. Delegate every user question and task to the client, including simple arithmetic. " +
- "The client agent owns all reasoning and task execution. Do not answer questions yourself or invent task results. " +
- "Speak only verified client results; do not read private reasoning or machine identifiers.",
- audio: { output: { voice: input.voice ?? "cove" } },
- delegation: { type: "client", ack_filler: false },
- ...(input.input && input.input.length > 0 ? { initial_items: input.input } : {}),
- }),
- );
- const response = await fetch(`${url.href.replace(/\/$/u, "")}/realtime/calls`, {
- method: "POST",
- headers,
- body: form,
- redirect: "error",
- signal: AbortSignal.any([input.signal, AbortSignal.timeout(15_000)]),
- });
- if (!response.ok) throw new Error("External voice call creation failed");
- const location = response.headers.get("location");
- let callId: string | undefined;
- try {
- if (location)
- callId = new URL(location, url).pathname.match(
- /\/(?:calls|live)\/([A-Za-z0-9_-]{1,128})$/u,
- )?.[1];
- } catch {
- // Without a valid identity there is no safe remote hangup target.
- }
- if (!callId) {
- await response.body?.cancel().catch(() => undefined);
- throw new Error("External voice call identity is unavailable; remote cleanup is unconfirmed");
+ if (input.callId !== undefined && !isRealtimeCallId(input.callId))
+ throw new Error("External voice call identity is invalid");
+ let callId: string | undefined = input.callId;
+ let response: Response | undefined;
+ if (callId === undefined) {
+ const form = new FormData();
+ form.append("sdp", input.sdp);
+ form.append(
+ "session",
+ JSON.stringify({
+ model: model ?? "gpt-realtime",
+ instructions:
+ "Use natural Hong Kong Cantonese. Delegate every user question and task to the client, including simple arithmetic. " +
+ "The client agent owns all reasoning and task execution. Do not answer questions yourself or invent task results. " +
+ "Speak only verified client results; do not read private reasoning or machine identifiers.",
+ audio: { output: { voice: input.voice ?? "cove" } },
+ delegation: { type: "client", ack_filler: false },
+ ...(input.input && input.input.length > 0 ? { initial_items: input.input } : {}),
+ }),
+ );
+ response = await fetch(realtimeGatewayUrl(gateway, "/realtime/calls"), {
+ method: "POST",
+ headers,
+ body: form,
+ redirect: "error",
+ signal: AbortSignal.any([input.signal, AbortSignal.timeout(15_000)]),
+ });
+ if (!response.ok) throw new Error("External voice call creation failed");
+ callId = realtimeCallIdFromLocation(response.headers.get("location") ?? "");
+ if (!callId) {
+ await response?.body?.cancel().catch(() => undefined);
+ throw new Error(
+ "External voice call identity is unavailable; remote cleanup is unconfirmed",
+ );
+ }
}
let socket: WebSocket | undefined;
let closed = false;
@@ -130,20 +82,14 @@ export function configuredExternalVoiceGateway(
(closeTask ??= (async () => {
closed = true;
socket?.terminate();
- const result = await fetch(
- `${url.href.replace(/\/$/u, "")}/realtime/calls/${callId}/hangup`,
- {
- method: "POST",
- headers,
- redirect: "error",
- signal: AbortSignal.timeout(10_000),
- },
- );
- if (!result.ok && result.status !== 404)
+ try {
+ await hangupRealtimeCall(gateway, callId);
+ } catch {
throw new Error("External voice hangup was not confirmed");
+ }
})());
try {
- const sdp = await response.text();
+ const sdp = response ? await response.text() : "";
if (input.signal.aborted) {
await close();
throw new Error("External voice start cancelled");
diff --git a/packages/host-runtime/src/external-voice.ts b/packages/host-runtime/src/external-voice.ts
index 7b2d692e9..24ed9fb5d 100644
--- a/packages/host-runtime/src/external-voice.ts
+++ b/packages/host-runtime/src/external-voice.ts
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
+import { isRealtimeCallId } from "@codexhost/shared-contracts";
import type { JsonObject } from "@codexhost/protocol-core";
import type { ExternalVoiceCall, ExternalVoiceCallFactory } from "./external-voice-gateway.js";
@@ -78,16 +79,13 @@ export class ExternalVoice {
async start(threadId: string, params: JsonObject, conversation?: JsonObject[]): Promise {
if (this.has(threadId)) throw new Error("This chat already has a voice call");
- if (
- !object(params.transport) ||
- params.transport.type !== "webrtc" ||
- typeof params.transport.sdp !== "string" ||
- !params.transport.sdp.startsWith("v=0") ||
- params.transport.sdp.length > 256_000 ||
- params.outputModality !== "audio"
- ) {
+ const transport = object(params.transport) ? params.transport : undefined;
+ const callId = transport?.callId;
+ const existingCall = transport?.type === "existingCall" && isRealtimeCallId(callId);
+ const sdp = transport?.type === "webrtc" ? transport.sdp : undefined;
+ const webrtc = typeof sdp === "string" && sdp.startsWith("v=0") && sdp.length <= 256_000;
+ if ((!existingCall && !webrtc) || params.outputModality !== "audio")
throw new Error("External voice requires the native WebRTC audio call transport");
- }
if (params.voice != null && (typeof params.voice !== "string" || !voices.has(params.voice))) {
throw new Error("Selected voice is unavailable for the external live-call protocol");
}
@@ -104,7 +102,8 @@ export class ExternalVoice {
};
try {
session.creation = this.options.createCall({
- sdp: params.transport.sdp,
+ sdp: webrtc ? String(sdp) : "",
+ ...(existingCall ? { callId: String(callId) } : {}),
...(typeof params.voice === "string" ? { voice: params.voice } : {}),
...(conversation && conversation.length > 0 ? { input: conversation } : {}),
signal: session.controller.signal,
@@ -123,11 +122,13 @@ export class ExternalVoice {
requireActive();
await this.options.notify("thread/realtime/started", {
threadId,
- realtimeSessionId: randomUUID(),
+ realtimeSessionId:
+ typeof params.realtimeSessionId === "string" ? params.realtimeSessionId : randomUUID(),
version: "v3",
});
requireActive();
- await this.options.notify("thread/realtime/sdp", { threadId, sdp: session.call.sdp });
+ if (!existingCall)
+ await this.options.notify("thread/realtime/sdp", { threadId, sdp: session.call.sdp });
requireActive();
session.initializationTimeout = setTimeout(() => {
void this.#fail(threadId, session);
diff --git a/packages/host-runtime/src/native-voice-gateway.ts b/packages/host-runtime/src/native-voice-gateway.ts
new file mode 100644
index 000000000..49f39a666
--- /dev/null
+++ b/packages/host-runtime/src/native-voice-gateway.ts
@@ -0,0 +1,126 @@
+import {
+ filterRealtimeCallHeaders,
+ isRealtimeCallId,
+ realtimeCallIdFromLocation,
+ realtimeCallParamsSchema,
+} from "@codexhost/shared-contracts";
+import {
+ hangupRealtimeCall,
+ readRealtimeGateway,
+ realtimeGatewayUrl,
+} from "./realtime-gateway-config.js";
+
+const RESPONSE_HEADERS = ["content-type", "location", "retry-after"];
+const MAX_RESPONSE_BYTES = 16 << 20;
+const MAX_PENDING_CALLS = 16;
+
+/** The `create` body must carry the native SDP offer and session object. */
+function createCallBody(value: string): string {
+ let payload: unknown;
+ try {
+ payload = JSON.parse(value);
+ } catch {
+ throw new Error("Invalid voice call request");
+ }
+ if (
+ !payload ||
+ typeof payload !== "object" ||
+ !("sdp" in payload) ||
+ typeof payload.sdp !== "string" ||
+ !("session" in payload) ||
+ !payload.session ||
+ typeof payload.session !== "object"
+ )
+ throw new Error("Invalid voice call request");
+ return value;
+}
+
+/** Serves Desktop-owned call creation using the configured gateway credential. */
+export class NativeVoiceGateway {
+ readonly #pending = new Map();
+ constructor(private readonly environment: NodeJS.ProcessEnv) {}
+
+ close(): void {
+ for (const controller of this.#pending.values()) controller.abort();
+ this.#pending.clear();
+ }
+
+ async request(value: unknown) {
+ const params = realtimeCallParamsSchema.parse(value);
+ if (params.operation === "cancel") {
+ this.#pending.get(params.requestId)?.abort();
+ return { status: 204, body: "", headers: {} };
+ }
+ if (params.operation === "hangup") {
+ const gateway = await readRealtimeGateway(this.environment);
+ await hangupRealtimeCall(gateway, params.callId);
+ return { status: 204, body: "", headers: {} };
+ }
+ if (this.#pending.has(params.requestId) || this.#pending.size >= MAX_PENDING_CALLS)
+ throw new Error("Voice call creation is already pending");
+ const controller = new AbortController();
+ this.#pending.set(params.requestId, controller);
+ try {
+ return await this.#create(params.body, params.headers, controller);
+ } finally {
+ this.#pending.delete(params.requestId);
+ }
+ }
+
+ async #create(
+ body: string,
+ forwarded: Record,
+ controller: AbortController,
+ ): Promise<{ status: number; body: string; headers: Record }> {
+ // Validated before the transport try, so these stay specific for direct callers.
+ const payload = createCallBody(body);
+ const gateway = await readRealtimeGateway(this.environment);
+ const headers = new Headers({
+ Authorization: `Bearer ${gateway.token}`,
+ "Content-Type": "application/json",
+ });
+ for (const [name, value] of Object.entries(filterRealtimeCallHeaders(forwarded)))
+ headers.set(name, value);
+ let closeCreatedCall: (() => Promise) | undefined;
+ try {
+ controller.signal.throwIfAborted();
+ const response = await fetch(realtimeGatewayUrl(gateway, "/realtime/calls"), {
+ method: "POST",
+ headers,
+ body: payload,
+ redirect: "error",
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]),
+ });
+ if (response.ok) {
+ const callId = realtimeCallIdFromLocation(response.headers.get("location") ?? "");
+ if (!isRealtimeCallId(callId)) {
+ await response.body?.cancel();
+ throw new Error("Voice call identity is unavailable; remote cleanup is unconfirmed");
+ }
+ closeCreatedCall = () => hangupRealtimeCall(gateway, callId);
+ }
+ const resultHeaders: Record = {};
+ for (const name of RESPONSE_HEADERS) {
+ const value = response.headers.get(name);
+ if (value !== null) resultHeaders[name] = value;
+ }
+ const text = await response.text();
+ if (text.length > MAX_RESPONSE_BYTES) throw new Error("Voice gateway response is too large");
+ controller.signal.throwIfAborted();
+ return { status: response.status, body: text, headers: resultHeaders };
+ } catch (error) {
+ if (closeCreatedCall) {
+ try {
+ await closeCreatedCall();
+ } catch {
+ throw new Error("Voice gateway startup failed; remote cleanup is unconfirmed");
+ }
+ }
+ if (controller.signal.aborted) throw new Error("Voice call creation cancelled");
+ // Transport diagnostics must not expose headers, credentials or SDP.
+ if (error instanceof Error && /^(Invalid voice|Voice call identity)/u.test(error.message))
+ throw error;
+ throw new Error("Configured voice gateway request failed");
+ }
+ }
+}
diff --git a/packages/host-runtime/src/realtime-gateway-config.ts b/packages/host-runtime/src/realtime-gateway-config.ts
new file mode 100644
index 000000000..28048e022
--- /dev/null
+++ b/packages/host-runtime/src/realtime-gateway-config.ts
@@ -0,0 +1,92 @@
+import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import path from "node:path";
+import { parse } from "smol-toml";
+
+function object(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Gateway credentials belong to the selected provider, never Desktop OAuth. */
+export async function readRealtimeGateway(environment: NodeJS.ProcessEnv) {
+ let config: Record;
+ try {
+ config = parse(
+ await readFile(
+ path.join(environment.CODEX_HOME ?? path.join(homedir(), ".codex"), "config.toml"),
+ "utf8",
+ ),
+ );
+ } catch {
+ throw new Error("External voice gateway configuration is unavailable");
+ }
+ const providers = config.model_providers;
+ const provider =
+ object(providers) && typeof config.model_provider === "string"
+ ? providers[config.model_provider]
+ : undefined;
+ const token = object(provider)
+ ? typeof provider.experimental_bearer_token === "string"
+ ? provider.experimental_bearer_token
+ : typeof provider.env_key === "string"
+ ? environment[provider.env_key]
+ : undefined
+ : undefined;
+ const base = config.experimental_realtime_webrtc_call_base_url;
+ const sidebandBase = config.experimental_realtime_ws_base_url ?? base;
+ if (typeof base !== "string" || typeof sidebandBase !== "string" || !token)
+ throw new Error("External voice requires a configured realtime gateway and API credential");
+ let url: URL;
+ let sideband: URL;
+ try {
+ url = new URL(base);
+ sideband = new URL(sidebandBase);
+ } catch {
+ throw new Error("External voice gateway URL is invalid");
+ }
+ if (
+ url.protocol !== "https:" ||
+ sideband.protocol !== "https:" ||
+ url.origin !== sideband.origin ||
+ url.username ||
+ url.password ||
+ sideband.username ||
+ sideband.password ||
+ url.search ||
+ url.hash ||
+ sideband.search ||
+ sideband.hash
+ )
+ throw new Error("External voice requires matching HTTPS gateway origins");
+ return {
+ url,
+ sideband,
+ token,
+ model:
+ typeof config.experimental_realtime_ws_model === "string"
+ ? config.experimental_realtime_ws_model
+ : undefined,
+ };
+}
+
+type RealtimeGateway = Awaited>;
+
+/** Gateway request URLs share one normalized base; only the path suffix differs. */
+export function realtimeGatewayUrl(gateway: RealtimeGateway, suffix: string): string {
+ return `${gateway.url.href.replace(/\/$/u, "")}${suffix}`;
+}
+
+/**
+ * Confirms remote cleanup with the configured credential. A 404 means the call is
+ * already gone; any other non-OK status leaves cleanup unconfirmed.
+ */
+export async function hangupRealtimeCall(gateway: RealtimeGateway, callId: string): Promise {
+ const response = await fetch(realtimeGatewayUrl(gateway, `/realtime/calls/${callId}/hangup`), {
+ method: "POST",
+ headers: { Authorization: `Bearer ${gateway.token}` },
+ redirect: "error",
+ signal: AbortSignal.timeout(10_000),
+ });
+ await response.body?.cancel();
+ if (!response.ok && response.status !== 404) throw new Error("Realtime call hangup unconfirmed");
+}
diff --git a/packages/host-runtime/test/app-server-host-fixture.ts b/packages/host-runtime/test/app-server-host-fixture.ts
index b67e56424..12ba1120c 100644
--- a/packages/host-runtime/test/app-server-host-fixture.ts
+++ b/packages/host-runtime/test/app-server-host-fixture.ts
@@ -123,7 +123,7 @@ export function method(message: JsonObject, value: string): boolean {
return message.method === value;
}
-export function requestId(message: JsonObject, id: number): boolean {
+export function requestId(message: JsonObject, id: string | number): boolean {
return message.id === id;
}
diff --git a/packages/host-runtime/test/external-voice-gateway.test.ts b/packages/host-runtime/test/external-voice-gateway.test.ts
index bb7ae3324..3e5758da9 100644
--- a/packages/host-runtime/test/external-voice-gateway.test.ts
+++ b/packages/host-runtime/test/external-voice-gateway.test.ts
@@ -64,6 +64,20 @@ afterEach(async () => {
});
describe("configured voice gateway", () => {
+ it("joins a Desktop-created call using gateway auth without another creation request", async () => {
+ const request = vi.fn().mockResolvedValue(new Response());
+ vi.stubGlobal("fetch", request);
+ const factory = await gateway();
+ const call = await factory({ ...input(), sdp: "", callId: "rtc_fixture" });
+ expect(request).not.toHaveBeenCalled();
+ await call.connect();
+ expect(connections[0]?.url).toBe("wss://gateway.example/v1/live/rtc_fixture");
+ await call.close();
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(request.mock.calls[0]?.[0]).toBe(
+ "https://gateway.example/v1/realtime/calls/rtc_fixture/hangup",
+ );
+ });
it.each(["http://[invalid", "/unrecognized", ""])(
"rejects an unusable call identity without claiming remote cleanup: %s",
async (location) => {
diff --git a/packages/host-runtime/test/external-voice-lifecycle.test.ts b/packages/host-runtime/test/external-voice-lifecycle.test.ts
index 4e9c3d141..c94e476b4 100644
--- a/packages/host-runtime/test/external-voice-lifecycle.test.ts
+++ b/packages/host-runtime/test/external-voice-lifecycle.test.ts
@@ -24,6 +24,30 @@ async function voiceSession() {
}
describe("voice lifecycle", () => {
+ it("attaches a Desktop-owned call without creating a second SDP exchange", async () => {
+ const createCall = vi.fn(async () => ({
+ sdp: "",
+ connect: vi.fn(async () => {}),
+ send: vi.fn(),
+ close: vi.fn(async () => {}),
+ }));
+ const notify = vi.fn(async () => {});
+ const voice = new ExternalVoice({ createCall, notify, runTask: vi.fn(), diagnose: vi.fn() });
+ await voice.start("external", {
+ outputModality: "audio",
+ realtimeSessionId: "native-session",
+ transport: { type: "existingCall", callId: "rtc_fixture" },
+ });
+ expect(createCall).toHaveBeenCalledWith(
+ expect.objectContaining({ callId: "rtc_fixture", sdp: "" }),
+ );
+ expect(notify).toHaveBeenCalledWith(
+ "thread/realtime/started",
+ expect.objectContaining({ realtimeSessionId: "native-session" }),
+ );
+ expect(notify.mock.calls.some(([method]) => method === "thread/realtime/sdp")).toBe(false);
+ await voice.close();
+ });
it("answers a native handoff before turn.done and deduplicates its later transcript", async () => {
const session = await voiceSession();
session.task.mockResolvedValue("Two empty folders.");
diff --git a/packages/host-runtime/test/native-voice-gateway.test.ts b/packages/host-runtime/test/native-voice-gateway.test.ts
new file mode 100644
index 000000000..e141344f1
--- /dev/null
+++ b/packages/host-runtime/test/native-voice-gateway.test.ts
@@ -0,0 +1,169 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { NativeVoiceGateway } from "../src/native-voice-gateway.js";
+import { REALTIME_CALL_METHOD } from "@codexhost/shared-contracts";
+import { createFixture, requestId, stopFixture, writeRequest } from "./app-server-host-fixture.js";
+
+const homes: string[] = [];
+async function gateway() {
+ const home = await mkdtemp(path.join(tmpdir(), "native-voice-gateway-"));
+ homes.push(home);
+ await writeFile(
+ path.join(home, "config.toml"),
+ 'model_provider="custom"\nexperimental_realtime_webrtc_call_base_url="https://gateway.example/v1"\nexperimental_realtime_ws_base_url="https://gateway.example/v1"\n[model_providers.custom]\nenv_key="VOICE_KEY"\n',
+ );
+ await writeFile(
+ path.join(home, "auth.json"),
+ JSON.stringify({ access_token: "desktop-oauth-must-not-be-used" }),
+ );
+ return new NativeVoiceGateway({ CODEX_HOME: home, VOICE_KEY: "gateway-fixture" });
+}
+const create = () => ({
+ operation: "create",
+ requestId: "create-fixture",
+ body: JSON.stringify({ sdp: "offer", session: { model: "gpt-live-1.1-codex" } }),
+ headers: {
+ Authorization: "Bearer desktop-oauth",
+ "ChatGPT-Account-Id": "disabled-account",
+ "OpenAI-Alpha": "quicksilver=v2",
+ "Thread-Id": "thread-fixture",
+ },
+});
+afterEach(async () => {
+ vi.unstubAllGlobals();
+ await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true })));
+});
+describe("native voice gateway", () => {
+ it("handles the Desktop RPC without forwarding it to official Codex", async () => {
+ const request = vi.fn().mockResolvedValue(
+ new Response("answer", {
+ status: 201,
+ headers: { location: "/v1/realtime/calls/rtc_fixture" },
+ }),
+ );
+ vi.stubGlobal("fetch", request);
+ await gateway();
+ const home = homes.at(-1);
+ if (!home) throw new Error("Missing gateway config");
+ const fixture = createFixture({
+ environment: { CODEX_HOME: home, VOICE_KEY: "gateway-fixture" },
+ });
+ try {
+ writeRequest(fixture.desktopInput, {
+ id: "voice-create",
+ method: REALTIME_CALL_METHOD,
+ params: create(),
+ });
+ const response = await fixture.collector.waitFor((message) =>
+ requestId(message, "voice-create"),
+ );
+ expect(response).toMatchObject({ result: { status: 201, body: "answer" } });
+ expect(request).toHaveBeenCalledOnce();
+ expect(fixture.official.stdin.readableLength).toBe(0);
+ } finally {
+ await stopFixture(fixture);
+ }
+ });
+ it("hangs up a known created call if reading its answer fails", async () => {
+ const failedBody = new ReadableStream({
+ start(controller) {
+ controller.error(new Error("transport secret"));
+ },
+ });
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(failedBody, {
+ status: 201,
+ headers: { location: "/v1/realtime/calls/rtc_fixture" },
+ }),
+ )
+ .mockResolvedValueOnce(new Response(null, { status: 204 }));
+ vi.stubGlobal("fetch", request);
+ await expect((await gateway()).request(create())).rejects.toThrow(
+ "Configured voice gateway request failed",
+ );
+ expect(request).toHaveBeenCalledTimes(2);
+ expect(request.mock.calls[1]?.[0]).toBe(
+ "https://gateway.example/v1/realtime/calls/rtc_fixture/hangup",
+ );
+ });
+ it("uses configured URLs and gateway auth, dropping Desktop account credentials", async () => {
+ const fetcher = vi.fn().mockResolvedValue(
+ new Response("answer", {
+ status: 201,
+ headers: { location: "/v1/realtime/calls/rtc_fixture", "set-cookie": "private" },
+ }),
+ );
+ vi.stubGlobal("fetch", fetcher);
+ const service = await gateway();
+ expect(await service.request(create())).toEqual({
+ status: 201,
+ body: "answer",
+ headers: {
+ "content-type": "text/plain;charset=UTF-8",
+ location: "/v1/realtime/calls/rtc_fixture",
+ },
+ });
+ const creation = fetcher.mock.calls[0];
+ if (!creation) throw new Error("Missing voice call request");
+ const [url, init] = creation;
+ expect(url).toBe("https://gateway.example/v1/realtime/calls");
+ const headers = new Headers(init?.headers);
+ expect(headers.get("authorization")).toBe("Bearer gateway-fixture");
+ expect(headers.has("chatgpt-account-id")).toBe(false);
+ expect(headers.get("thread-id")).toBe("thread-fixture");
+ expect(init?.body).toBe(create().body);
+ expect(init?.redirect).toBe("error");
+ });
+ it("preserves quota status and retry-after for the native caller", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response("quota", { status: 429, headers: { "retry-after": "60" } }),
+ ),
+ );
+ expect(await (await gateway()).request(create())).toMatchObject({
+ status: 429,
+ body: "quota",
+ headers: { "retry-after": "60" },
+ });
+ });
+ it("rejects arbitrary URL operations and malformed payloads before sending", async () => {
+ const fetcher = vi.fn();
+ vi.stubGlobal("fetch", fetcher);
+ const service = await gateway();
+ await expect(
+ service.request({ operation: "fetch", url: "https://other.example" }),
+ ).rejects.toThrow();
+ await expect(service.request({ ...create(), body: "{}" })).rejects.toThrow("Invalid voice");
+ expect(fetcher).not.toHaveBeenCalled();
+ });
+ it("cancels an in-flight bootstrap without leaking transport diagnostics", async () => {
+ let ready!: () => void;
+ const started = new Promise((resolve) => {
+ ready = resolve;
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ (_url, options) =>
+ new Promise((_resolve, reject) => {
+ ready();
+ options.signal.addEventListener("abort", () =>
+ reject(new Error("secret upstream detail")),
+ );
+ }),
+ ),
+ );
+ const service = await gateway();
+ const result = service.request(create());
+ await started;
+ await service.request({ operation: "cancel", requestId: "create-fixture" });
+ await expect(result).rejects.toThrow("cancelled");
+ });
+});
diff --git a/packages/renderer-extension/src/cpa-live-voice-entry.ts b/packages/renderer-extension/src/cpa-live-voice-entry.ts
index 27d7b52c9..78ca586bd 100644
--- a/packages/renderer-extension/src/cpa-live-voice-entry.ts
+++ b/packages/renderer-extension/src/cpa-live-voice-entry.ts
@@ -1,6 +1,8 @@
import { installCpaLiveVoiceHook } from "./renderer-cpa-live-voice-hook.js";
+import { installClientOwnedVoiceHook } from "./renderer-client-owned-voice.js";
import { installOverlayThreadIsolation } from "./renderer-overlay-thread-isolation.js";
// Document-start: do not wait for body. Voice constructs clientOwnsCall during start.
installCpaLiveVoiceHook();
+installClientOwnedVoiceHook();
installOverlayThreadIsolation();
diff --git a/packages/renderer-extension/src/production-entry.ts b/packages/renderer-extension/src/production-entry.ts
index 42c2fac9e..36b389d4a 100644
--- a/packages/renderer-extension/src/production-entry.ts
+++ b/packages/renderer-extension/src/production-entry.ts
@@ -1,8 +1,10 @@
import { installCpaLiveVoiceHook } from "./renderer-cpa-live-voice-hook.js";
+import { installClientOwnedVoiceHook } from "./renderer-client-owned-voice.js";
import { installRendererBinding } from "./install-renderer-binding.js";
import { installExternalVoiceLaunchHook } from "./renderer-external-voice-launch.js";
installCpaLiveVoiceHook();
+installClientOwnedVoiceHook();
installExternalVoiceLaunchHook();
const installBinding = (): void => {
diff --git a/packages/renderer-extension/src/renderer-client-owned-voice.ts b/packages/renderer-extension/src/renderer-client-owned-voice.ts
new file mode 100644
index 000000000..d7ec49f0b
--- /dev/null
+++ b/packages/renderer-extension/src/renderer-client-owned-voice.ts
@@ -0,0 +1,220 @@
+import {
+ REALTIME_CALL_METHOD,
+ filterRealtimeCallHeaders,
+ isRealtimeCallId,
+ realtimeCallIdFromLocation,
+ realtimeCallResponseSchema,
+} from "@codexhost/shared-contracts";
+import {
+ auditedAppInitialAssetPresent,
+ loadedAssetNames,
+ readCpaLiveVoiceHookConfig,
+} from "./renderer-cpa-live-voice-hook.js";
+import { cpaLiveVoicePreferenceAllowsInstall } from "./renderer-cpa-live-voice-preference.js";
+
+export const CLIENT_OWNED_VOICE_VERSION = "26.1013.40934";
+export const CLIENT_OWNED_VOICE_INITIAL_ASSET = "app-initial-90eb42395b7d.js";
+export const CLIENT_OWNED_VOICE_SHARED_ASSET = "app-shared-52443cbe9ac4.js";
+const HOOK_GLOBAL = "__codexhostClientOwnedVoiceV1";
+type RecordValue = Record;
+function object(value: unknown): value is RecordValue {
+ return value !== null && typeof value === "object";
+}
+
+export interface NativeVoiceBridge {
+ dispatchMessage(type: string, message: RecordValue): void;
+ subscribe(type: string, listener: (message: unknown) => void): () => void;
+}
+
+function requestHost(bridge: NativeVoiceBridge, params: RecordValue): Promise {
+ const id = `codexhost-voice:${crypto.randomUUID()}`;
+ return new Promise((resolve, reject) => {
+ let unsubscribe = () => {};
+ const timer = setTimeout(() => {
+ unsubscribe();
+ reject(new Error("Voice gateway did not answer in time"));
+ }, 30_000);
+ const done = () => {
+ clearTimeout(timer);
+ unsubscribe();
+ };
+ unsubscribe = bridge.subscribe("mcp-response", (event) => {
+ if (
+ !object(event) ||
+ event.hostId !== "local" ||
+ !object(event.message) ||
+ event.message.id !== id
+ )
+ return;
+ done();
+ if (event.message.error) reject(new Error("Configured voice gateway request failed"));
+ else resolve(event.message.result);
+ });
+ try {
+ bridge.dispatchMessage("mcp-request", {
+ hostId: "local",
+ request: { id, method: REALTIME_CALL_METHOD, params },
+ });
+ } catch {
+ done();
+ reject(new Error("Voice gateway Host connection is unavailable"));
+ }
+ });
+}
+
+interface NativeVoiceFetch {
+ fetch(url: string, options?: RequestInit): Promise;
+}
+
+/** Only the audited native WebFetch voice bootstrap is forwarded. Other native
+ * requests retain their receiver, arguments, authentication and error behavior.
+ */
+export function wrapClientOwnedVoiceFetch(
+ client: NativeVoiceFetch,
+ bridge: NativeVoiceBridge,
+ enabled: () => boolean,
+ responseError: (response: Response, url: string) => Promise,
+): () => void {
+ const original = client.fetch;
+ const wrapped: NativeVoiceFetch["fetch"] = async function (this: NativeVoiceFetch, url, options) {
+ if (
+ typeof url !== "string" ||
+ url.split("?")[0] !== "/wham/realtime/calls" ||
+ options?.method !== "POST" ||
+ !enabled()
+ )
+ return original.call(this, url, options);
+ if (typeof options.body !== "string") throw new Error("Unsupported native voice call request");
+ options.signal?.throwIfAborted();
+ const requestId = crypto.randomUUID();
+ let cancelled = false;
+ const cancel = () => {
+ if (cancelled) return;
+ cancelled = true;
+ void requestHost(bridge, { operation: "cancel", requestId }).catch(() => undefined);
+ };
+ options.signal?.addEventListener("abort", cancel, { once: true });
+ try {
+ const result = realtimeCallResponseSchema.parse(
+ await requestHost(bridge, {
+ operation: "create",
+ requestId,
+ body: options.body,
+ // Drop Desktop OAuth, ChatGPT account identity and cookies at the bridge
+ // boundary; the Host allowlist repeats this check before sending.
+ headers: filterRealtimeCallHeaders(Object.fromEntries(new Headers(options.headers))),
+ }),
+ );
+ if (options.signal?.aborted) {
+ const callId = realtimeCallIdFromLocation(result.headers.location ?? "");
+ if (result.status >= 200 && result.status < 300 && isRealtimeCallId(callId))
+ await requestHost(bridge, { operation: "hangup", callId });
+ options.signal.throwIfAborted();
+ }
+ const response = new Response(result.status === 204 ? null : result.body, {
+ status: result.status,
+ headers: result.headers,
+ });
+ if (!response.ok) throw await responseError(response, url);
+ return response;
+ } catch (error) {
+ cancel();
+ throw error;
+ } finally {
+ options.signal?.removeEventListener("abort", cancel);
+ }
+ };
+ client.fetch = wrapped;
+ return () => {
+ if (client.fetch === wrapped) client.fetch = original;
+ };
+}
+
+interface VoiceTarget {
+ [key: string]: unknown;
+ document?: ParentNode;
+ performance?: Pick;
+ setTimeout: typeof setTimeout;
+ clearTimeout: typeof clearTimeout;
+}
+interface VoiceHook {
+ status: "disabled" | "pending" | "active" | "unsupported";
+ dispose(): void;
+}
+
+/** Desktop 26.1013 always creates calls in the Renderer. Its native WebFetch
+ * singleton and message bridge are audited exports of the loaded shared module.
+ */
+export function installClientOwnedVoiceHook(
+ options: {
+ target?: VoiceTarget;
+ importModule?: (url: string) => Promise;
+ } = {},
+): VoiceHook {
+ const target = options.target ?? (globalThis as unknown as VoiceTarget);
+ const existing = target[HOOK_GLOBAL] as VoiceHook | undefined;
+ if (existing) return existing;
+ const config = readCpaLiveVoiceHookConfig(target);
+ if (!config || !cpaLiveVoicePreferenceAllowsInstall(target))
+ return { status: "disabled", dispose() {} };
+ if (config.desktopVersion !== undefined && config.desktopVersion !== CLIENT_OWNED_VOICE_VERSION)
+ return { status: "unsupported", dispose() {} };
+ const importModule = options.importModule ?? ((url: string) => import(url));
+ let disposed = false;
+ let timer: ReturnType | undefined;
+ let restore: (() => void) | undefined;
+ const started = Date.now();
+ const handle: VoiceHook = {
+ status: "pending",
+ dispose() {
+ disposed = true;
+ if (timer !== undefined) target.clearTimeout(timer);
+ restore?.();
+ if (target[HOOK_GLOBAL] === handle) Reflect.deleteProperty(target, HOOK_GLOBAL);
+ },
+ };
+ target[HOOK_GLOBAL] = handle;
+ const attempt = async (): Promise => {
+ if (disposed) return;
+ const names = loadedAssetNames(target.document, target.performance);
+ if (
+ auditedAppInitialAssetPresent(names, CLIENT_OWNED_VOICE_INITIAL_ASSET) &&
+ auditedAppInitialAssetPresent(names, CLIENT_OWNED_VOICE_SHARED_ASSET)
+ ) {
+ try {
+ const module = await importModule(`app://-/assets/${CLIENT_OWNED_VOICE_SHARED_ASSET}`);
+ if (disposed) return;
+ const fetchClass = module.urt;
+ const bridgeClass = module.mPt;
+ if (!object(fetchClass) && typeof fetchClass !== "function") throw new Error();
+ if (!object(bridgeClass) && typeof bridgeClass !== "function") throw new Error();
+ const client = Reflect.get(fetchClass, "getInstance").call(fetchClass);
+ const bridge = Reflect.get(bridgeClass, "getInstance").call(bridgeClass);
+ if (
+ typeof client?.fetch !== "function" ||
+ typeof bridge?.dispatchMessage !== "function" ||
+ typeof bridge?.subscribe !== "function" ||
+ typeof module.grt !== "function"
+ )
+ throw new Error();
+ restore = wrapClientOwnedVoiceFetch(
+ client,
+ bridge,
+ () => cpaLiveVoicePreferenceAllowsInstall(target),
+ module.grt as (response: Response, url: string) => Promise,
+ );
+ handle.status = "active";
+ } catch {
+ handle.status = "unsupported";
+ }
+ return;
+ }
+ if (Date.now() - started >= 60_000) {
+ handle.status = "unsupported";
+ return;
+ }
+ timer = target.setTimeout(() => void attempt(), 250);
+ };
+ void attempt();
+ return handle;
+}
diff --git a/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts b/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts
index 5a30e5343..3644281dd 100644
--- a/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts
+++ b/packages/renderer-extension/src/renderer-cpa-live-voice-hook.ts
@@ -78,18 +78,23 @@ export function auditedAppInitialAssetPresent(
});
}
-function resourceNamesFromPerformance(): string[] {
- if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
- return [];
- }
- return performance
+function resourceNamesFromPerformance(
+ source: Pick | undefined,
+): string[] {
+ const target = source ?? (typeof performance === "undefined" ? undefined : performance);
+ if (!target || typeof target.getEntriesByType !== "function") return [];
+ return target
.getEntriesByType("resource")
.map((entry) => entry.name)
.filter((name): name is string => typeof name === "string");
}
-function resourceNamesFromDocument(root: ParentNode | undefined): string[] {
- const names = resourceNamesFromPerformance();
+/** Every loaded asset name this page exposes: resource timing plus script/link tags. */
+export function loadedAssetNames(
+ root: ParentNode | undefined,
+ performanceSource?: Pick,
+): string[] {
+ const names = resourceNamesFromPerformance(performanceSource);
if (!root || !("querySelectorAll" in root)) return names;
for (const element of root.querySelectorAll("script[src], link[href]")) {
const value = element.getAttribute("src") ?? element.getAttribute("href");
@@ -167,7 +172,7 @@ export function installCpaLiveVoiceHook(
};
const attempt = (): boolean => {
if (!auditedAssetFound) {
- const resourceNames = options.resourceNames ?? resourceNamesFromDocument(root);
+ const resourceNames = options.resourceNames ?? loadedAssetNames(root);
auditedAssetFound = configAllowsInstall(config, resourceNames);
if (!auditedAssetFound) return false;
}
diff --git a/packages/renderer-extension/src/renderer-external-voice-launch.ts b/packages/renderer-extension/src/renderer-external-voice-launch.ts
index bf866387e..b991ba0f4 100644
--- a/packages/renderer-extension/src/renderer-external-voice-launch.ts
+++ b/packages/renderer-extension/src/renderer-external-voice-launch.ts
@@ -1,8 +1,14 @@
+import {
+ CLIENT_OWNED_VOICE_VERSION,
+ CLIENT_OWNED_VOICE_INITIAL_ASSET,
+ CLIENT_OWNED_VOICE_SHARED_ASSET,
+} from "./renderer-client-owned-voice.js";
import type { RendererHostRouting } from "@codexhost/desktop-control/renderer-bindings";
import {
AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET,
AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION,
auditedAppInitialAssetPresent,
+ loadedAssetNames,
readCpaLiveVoiceHookConfig,
} from "./renderer-cpa-live-voice-hook.js";
@@ -76,7 +82,9 @@ export function installExternalVoiceLaunchHook(options: InstallOptions = {}): La
if (
config.auditedDesktopVersion !== AUDITED_CPA_LIVE_VOICE_DESKTOP_VERSION ||
config.auditedAppInitialAsset !== AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET ||
- (config.desktopVersion !== undefined && config.desktopVersion !== config.auditedDesktopVersion)
+ (config.desktopVersion !== undefined &&
+ config.desktopVersion !== config.auditedDesktopVersion &&
+ config.desktopVersion !== CLIENT_OWNED_VOICE_VERSION)
)
return { status: "unsupported", dispose() {} };
const importModule: (url: string) => Promise =
@@ -99,19 +107,19 @@ export function installExternalVoiceLaunchHook(options: InstallOptions = {}): La
const attempt = async (): Promise => {
if (disposed) return;
try {
- const names =
- target.performance?.getEntriesByType("resource").map((entry) => entry.name) ?? [];
- for (const element of target.document?.querySelectorAll("script[src], link[href]") ?? []) {
- const name = element.getAttribute("src") ?? element.getAttribute("href");
- if (name) names.push(name);
- }
- if (
+ const names = loadedAssetNames(target.document, target.performance);
+ const clientOwned =
+ auditedAppInitialAssetPresent(names, CLIENT_OWNED_VOICE_INITIAL_ASSET) &&
+ auditedAppInitialAssetPresent(names, CLIENT_OWNED_VOICE_SHARED_ASSET);
+ const legacy =
auditedAppInitialAssetPresent(names, AUDITED_CPA_LIVE_VOICE_APP_INITIAL_ASSET) &&
- auditedAppInitialAssetPresent(names, SHARED_ASSET)
- ) {
- module ??= await importModule(`app://-/assets/${SHARED_ASSET}`);
+ auditedAppInitialAssetPresent(names, SHARED_ASSET);
+ if (clientOwned || legacy) {
+ module ??= await importModule(
+ `app://-/assets/${clientOwned ? CLIENT_OWNED_VOICE_SHARED_ASSET : SHARED_ASSET}`,
+ );
if (disposed) return;
- const services = module?.fN;
+ const services = clientOwned ? module?.Kat : module?.fN;
if (isRecord(services)) {
const original = services.realtimeVoiceRuntime;
if (typeof original === "function" || isRecord(original)) {
diff --git a/packages/renderer-extension/test/renderer-client-owned-voice.test.ts b/packages/renderer-extension/test/renderer-client-owned-voice.test.ts
new file mode 100644
index 000000000..3ccc7b70a
--- /dev/null
+++ b/packages/renderer-extension/test/renderer-client-owned-voice.test.ts
@@ -0,0 +1,173 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { REALTIME_CALL_METHOD } from "@codexhost/shared-contracts";
+import {
+ CLIENT_OWNED_VOICE_INITIAL_ASSET,
+ CLIENT_OWNED_VOICE_SHARED_ASSET,
+ installClientOwnedVoiceHook,
+ wrapClientOwnedVoiceFetch,
+ type NativeVoiceBridge,
+} from "../src/renderer-client-owned-voice.js";
+import { CPA_LIVE_VOICE_HOOK_GLOBAL } from "../src/renderer-cpa-live-voice-hook.js";
+
+function fixture(
+ result = { status: 201, body: "answer", headers: { location: "/v1/realtime/calls/rtc_fixture" } },
+) {
+ const listeners = new Set<(event: unknown) => void>();
+ const dispatch = vi.fn((_type: string, value: Record) => {
+ const request = value.request as { id: string; params: { operation: string } };
+ queueMicrotask(() => {
+ for (const listener of listeners)
+ listener({
+ hostId: "local",
+ message: {
+ id: request.id,
+ result:
+ request.params.operation === "create"
+ ? result
+ : { status: 204, body: "", headers: {} },
+ },
+ });
+ });
+ });
+ const bridge: NativeVoiceBridge = {
+ dispatchMessage: dispatch,
+ subscribe: (_type, listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+ const original = vi.fn(async () => new Response("native"));
+ const client = { fetch: original as (url: string, options?: RequestInit) => Promise };
+ const responseError = vi.fn(async (response: Response) =>
+ Object.assign(new Error(await response.text()), { status: response.status }),
+ );
+ return { bridge, dispatch, listeners, client, original, responseError };
+}
+const url = "/wham/realtime/calls?intent=quicksilver&architecture=avas";
+const options = () => ({
+ method: "POST",
+ body: JSON.stringify({ sdp: "offer", session: { model: "gpt-live-1.1-codex" } }),
+});
+afterEach(() => vi.useRealTimers());
+
+describe("client-owned voice gateway", () => {
+ it("creates the native call through Host rather than Desktop OAuth", async () => {
+ const f = fixture();
+ const dispose = wrapClientOwnedVoiceFetch(f.client, f.bridge, () => true, f.responseError);
+ const response = await f.client.fetch(url, options());
+ expect(response.status).toBe(201);
+ expect(await response.text()).toBe("answer");
+ expect(response.headers.get("location")).toBe("/v1/realtime/calls/rtc_fixture");
+ expect(f.original).not.toHaveBeenCalled();
+ expect(f.dispatch).toHaveBeenCalledWith(
+ "mcp-request",
+ expect.objectContaining({
+ hostId: "local",
+ request: expect.objectContaining({
+ method: REALTIME_CALL_METHOD,
+ params: expect.objectContaining({ operation: "create", body: options().body }),
+ }),
+ }),
+ );
+ expect(f.listeners.size).toBe(0);
+ dispose();
+ expect(f.client.fetch).toBe(f.original);
+ });
+ it("strips Desktop account credentials and cookies before crossing the bridge", async () => {
+ const f = fixture();
+ wrapClientOwnedVoiceFetch(f.client, f.bridge, () => true, f.responseError);
+ await f.client.fetch(url, {
+ ...options(),
+ headers: {
+ Authorization: "Bearer desktop-oauth",
+ Cookie: "session=private",
+ "ChatGPT-Account-Id": "disabled-account",
+ "openai-alpha": "quicksilver=v2",
+ "Thread-Id": "thread-fixture",
+ },
+ });
+ const params = f.dispatch.mock.calls
+ .map(
+ ([, value]) => (value.request as { params: { headers?: Record } }).params,
+ )
+ .find((entry) => entry.headers !== undefined);
+ expect(params?.headers).toEqual({
+ "openai-alpha": "quicksilver=v2",
+ "thread-id": "thread-fixture",
+ });
+ const serialized = JSON.stringify(f.dispatch.mock.calls);
+ expect(serialized).not.toContain("desktop-oauth");
+ expect(serialized).not.toContain("disabled-account");
+ expect(serialized).not.toContain("session=private");
+ });
+ it.each([
+ "/other",
+ "/wham/realtime/calls/rtc_fixture/hangup",
+ "https://chatgpt.com/wham/realtime/calls",
+ "/wham/realtime/calls-extra",
+ ])("preserves unrelated requests: %s", async (path) => {
+ const f = fixture();
+ wrapClientOwnedVoiceFetch(f.client, f.bridge, () => true, f.responseError);
+ expect(await (await f.client.fetch(path, options())).text()).toBe("native");
+ expect(f.dispatch).not.toHaveBeenCalled();
+ });
+ it("honors the preference at call time", async () => {
+ const f = fixture();
+ wrapClientOwnedVoiceFetch(f.client, f.bridge, () => false, f.responseError);
+ await f.client.fetch(url, options());
+ expect(f.original).toHaveBeenCalledOnce();
+ expect(f.dispatch).not.toHaveBeenCalled();
+ });
+ it("preserves gateway quota errors without falling back to the signed-in account", async () => {
+ const f = fixture({ status: 429, body: "usage_limit_reached", headers: { location: "" } });
+ wrapClientOwnedVoiceFetch(f.client, f.bridge, () => true, f.responseError);
+ await expect(f.client.fetch(url, options())).rejects.toMatchObject({
+ message: "usage_limit_reached",
+ status: 429,
+ });
+ expect(f.original).not.toHaveBeenCalled();
+ });
+ it("hangs up an answer that arrives after cancellation", async () => {
+ const f = fixture();
+ const abort = new AbortController();
+ wrapClientOwnedVoiceFetch(f.client, f.bridge, () => true, f.responseError);
+ const creation = f.client.fetch(url, { ...options(), signal: abort.signal });
+ abort.abort();
+ await expect(creation).rejects.toMatchObject({ name: "AbortError" });
+ expect(
+ f.dispatch.mock.calls.map(([, value]) => (value.request as { params: unknown }).params),
+ ).toContainEqual({ operation: "hangup", callId: "rtc_fixture" });
+ });
+ it("installs only after both audited assets are present", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ const names = [CLIENT_OWNED_VOICE_INITIAL_ASSET];
+ const target = {
+ [CPA_LIVE_VOICE_HOOK_GLOBAL]: {
+ enabled: true,
+ auditedDesktopVersion: "26.1007.30912",
+ auditedAppInitialAsset: "app-initial-6350d04672d0.js",
+ },
+ document: {
+ querySelectorAll: () => names.map((name) => ({ getAttribute: () => `/assets/${name}` })),
+ } as unknown as ParentNode,
+ setTimeout,
+ clearTimeout,
+ };
+ const importModule = vi.fn(async () => ({
+ urt: { getInstance: () => f.client },
+ mPt: { getInstance: () => f.bridge },
+ grt: f.responseError,
+ }));
+ const hook = installClientOwnedVoiceHook({ target, importModule });
+ expect(importModule).not.toHaveBeenCalled();
+ names.push(CLIENT_OWNED_VOICE_SHARED_ASSET);
+ await vi.advanceTimersByTimeAsync(250);
+ expect(hook.status).toBe("active");
+ expect(importModule).toHaveBeenCalledWith(`app://-/assets/${CLIENT_OWNED_VOICE_SHARED_ASSET}`);
+ hook.dispose();
+ expect(f.client.fetch).toBe(f.original);
+ });
+});
diff --git a/packages/renderer-extension/test/renderer-external-voice-launch.test.ts b/packages/renderer-extension/test/renderer-external-voice-launch.test.ts
index d8c33e9d5..f2450aaf3 100644
--- a/packages/renderer-extension/test/renderer-external-voice-launch.test.ts
+++ b/packages/renderer-extension/test/renderer-external-voice-launch.test.ts
@@ -76,6 +76,23 @@ const settle = async () => {
afterEach(() => vi.useRealTimers());
describe("native new-voice selection", () => {
+ it("keeps the selected Harness on the client-owned-call Desktop build", async () => {
+ const f = hookFixture();
+ f.names.splice(0, f.names.length, "app-initial-90eb42395b7d.js", "app-shared-52443cbe9ac4.js");
+ const hook = installExternalVoiceLaunchHook({
+ target: f.target,
+ importModule: async () => ({ Kat: f.services }),
+ });
+ await settle();
+ expect(hook.status).toBe("active");
+ const routed = f.services.realtimeVoiceRuntime.requestRealtimeStart(request()) as {
+ args: unknown[];
+ };
+ expect(routed.args[0]).toMatchObject({
+ activeCollaborationMode: { settings: { model: carrier } },
+ });
+ hook.dispose();
+ });
it("snapshots the external route into a separately cloned overlay request", () => {
const { routing, policy } = routingFixture();
const original = request();
diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts
index ef8543092..39489eb40 100644
--- a/packages/shared-contracts/src/index.ts
+++ b/packages/shared-contracts/src/index.ts
@@ -7,6 +7,7 @@ export * from "./account-credits-presentation.js";
export * from "./model-price-overrides.js";
export * from "./usage-statistics.js";
export * from "./thread-prewarm.js";
+export * from "./realtime-call.js";
export {
CONSOLE_HOST_METHODS,
CONSOLE_OPEN_METHOD,
diff --git a/packages/shared-contracts/src/realtime-call.ts b/packages/shared-contracts/src/realtime-call.ts
new file mode 100644
index 000000000..d7fbf646a
--- /dev/null
+++ b/packages/shared-contracts/src/realtime-call.ts
@@ -0,0 +1,71 @@
+import { z } from "zod";
+
+export const REALTIME_CALL_METHOD = "codexhost/realtime/call";
+
+/**
+ * Native voice headers the Host may forward to the configured gateway. Anything
+ * else — Desktop OAuth, ChatGPT account identity and cookies — is dropped at the
+ * Renderer bridge and again at the Host before the outbound request.
+ */
+export const REALTIME_CALL_FORWARDED_HEADERS: readonly string[] = [
+ "openai-alpha",
+ "thread-id",
+ "session-id",
+ "originator",
+ "x-codex-turn-metadata",
+];
+const FORWARDED_HEADERS = new Set(REALTIME_CALL_FORWARDED_HEADERS);
+
+/** Keeps only allowlisted headers with a usable value; all other names are dropped. */
+export function filterRealtimeCallHeaders(
+ headers: Record