@@ -68,13 +68,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).
@@ -176,7 +176,7 @@ Harnesses integrate with Codex Desktop's native UI according to their capabiliti
| Streaming responses | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Tool status | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Edit Diff | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Questions / cancellation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Questions / cancellation | ✅ | ✅ | ✅ | ✅ | ◐ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Model / Thinking selection | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Tool approvals | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Permission modes | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -186,6 +186,21 @@ Harnesses integrate with Codex Desktop's native UI according to their capabiliti
| Context compaction | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
| Slash commands | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Edit previous message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Live voice chat | ? | ? | ✅ | ? | ◐ | ? | ? | ? | ? | ? | ? | ? | ? | ? |
+| Multi-select questions | — | ◐ | ◐ | ◐ | — | ◐ | — | ◐ | ◐ | ◐ | ◐ | ◐ | ◐ | ◐ |
+| Subagents | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | ◐ | — | — | — | — |
+| Computer Use / in-app browser | — | — | — | — | ◐ | — | — | — | — | — | — | — | — | — |
+| Generated images | — | — | — | — | ◐ | — | — | — | — | — | — | — | — | — |
+
+Legend: ✅ supported integration · ◐ partial, degraded, or saved intent only · — not exposed through the current plugin · ? unverified.
+
+- **Live voice chat** uses this fork's version-audited Desktop hook and a configured gateway. The Host `thread/realtime/*` path and new-chat Harness inheritance are generic. Claude Code has a documented attended Desktop call; Grok is ◐ because verification covers a real gateway call and an isolated Host trial with the public plugin, rather than installed Desktop button acceptance. Other Harnesses are unverified (?). See [CPA live-voice Desktop hook](docs/operations/cpa-live-voice-desktop-hook.md).
+- **Multi-select questions** do not have a multi-select Desktop card. The shared projector omits `multiple` while the Host contract retains it. In the reported delegated Claude Code test, `AskUserQuestion` with `multiSelect: true` and Alpha/Beta/Gamma rendered as single-choice and returned one string (`"Alpha"`), with no Host error. Claude Code, OpenCode, DSH, CodeBuddy/WorkBuddy, Cursor, Hermes, Qoder, Kimi Code and Copilot elicitation forms are ◐ because their adapters preserve native multi-select intent but cannot project it faithfully; only Claude Code was live-tested. OMP is ◐ for its documented native composition of separate single-choice/text interactions, not a multi-select card. Pi hardcodes single-choice; Antigravity explicitly rejects multi-select. See [Antigravity question interaction postmortem](docs/harnesses/antigravity/antigravity-question-interaction-postmortem.md) and [OMP interactions](docs/harnesses/omp/omp-interactions.md).
+- **Grok build and questions**: this fork uses a patched **grokgod Grok build**; the patch-dependent behavior below is not a stock-Grok guarantee. Patch `0011-ask-question-timeout-action` adds configurable timeout selection and activity-based timeout reset to the existing native `ask_user_question`; it does not connect a Desktop card. In the reported grokgod ACP test, the call failed with `Failed to reach the client for user question: "Method not found": _x.ai/ask_user_question`. The Host ACP client handles only `_x.ai/mcp/elicit` extension requests, so no question card appears. Questions / cancellation stays ◐: turn cancellation and bounded MCP consent approvals have separate implemented paths. See [Harness capability boundaries](docs/harnesses/capability-boundaries.md).
+- **Grok MCP approval — Always allow** requires grokgod patch `0025-mcp-elicitation-persist` to forward the native persist offer and return the selected scope as MCP `_meta.persist`. Desktop offers it only when the request includes `always`; unpatched Grok has Allow once / Deny only. Allow this conversation is not exposed because native session lifetime is not qualified. See [Grok MCP elicitation](docs/harnesses/grok/mcp-elicitation.md).
+- **Subagents**: ✅ native status plus a read-only child transcript · ◐ native status only. Pi requires the supported `pi-subagents` extension protocol; OMP requires RPC subagent subscription support. These cells describe plugin projection, not whether an upstream Harness can run subagents internally.
+- **Computer Use / in-app browser** uses the user-installed Grok `cua_repl` MCP. This fork's Computer Use integration requires the patched grokgod Grok build with `0026-cua-turn-end-release`, including turn-boundary cleanup and the Mac Sky native release bridge, plus the installed ChatGPT Computer Use runtime. Stock-Grok parity is not established. IAB is saved intent only: the Host keeps its browser binding disabled pending Desktop authorization and a session-owned browser route. See [Grok Computer Use settings](docs/harnesses/grok/computer-use-settings.md).
+- **Generated images** describes Grok Imagine projection to Desktop `imageGeneration`, not general image-file creation. Grok is the only current adapter emitting this Item; the other cells mean no dedicated gallery projection. Projection has test coverage, but live gallery display remains unverified. See [Grok generated images](docs/harnesses/grok/generated-images.md).
@@ -226,7 +241,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
@@ -291,7 +306,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
@@ -314,6 +329,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.
@@ -323,8 +340,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/launcher/src/desktop_attachment.rs b/crates/launcher/src/desktop_attachment.rs
index e5e84dca3..272bd76d9 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,178 @@ 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_controller_exit(descriptor, ORPHANED_CONTROLLER_STOP_GRACE)? {
+ return Ok(());
+ }
+ for process_id in matching_controller_process_ids(descriptor)? {
+ terminate_controller_process(process_id, true)?;
+ }
+ if wait_for_controller_exit(descriptor, 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
+}
+
+pub(super) 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(()),
+ }
+}
+
+pub(super) fn wait_for_controller_exit(
+ descriptor: &RuntimeDescriptor,
+ timeout: Duration,
+) -> Result> {
+ wait_for_known_controller_exit(descriptor, &[], timeout)
+}
+
+pub(super) fn wait_for_known_controller_exit(
+ descriptor: &RuntimeDescriptor,
+ known_process_ids: &[u32],
+ timeout: Duration,
+) -> Result> {
+ let started = Instant::now();
+ loop {
+ // A Controller can close its socket before its asynchronous cleanup finishes.
+ // The Launcher cannot finish shutdown while that process remains alive.
+ let known_alive = known_process_ids
+ .iter()
+ .copied()
+ .any(|process_id| process_snapshot(process_id).is_ok());
+ if matching_controller_process_ids(descriptor)?.is_empty()
+ && !known_alive
+ && !endpoint_ready(descriptor.control_port, Duration::from_millis(50))
+ {
+ return Ok(true);
+ }
+ if started.elapsed() >= timeout {
+ return Ok(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 +502,130 @@ 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, close_only: bool) -> std::process::Child {
+ let signal_handler = if close_only {
+ "signal.signal(signal.SIGTERM, lambda *_: server.close())\n"
+ } else {
+ ""
+ };
+ let script = format!(
+ "import socket, time, signal\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\
+ {signal_handler}\
+ 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, false);
+ 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_escalates_after_socket_closes_but_process_stays_alive() {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).expect("reserve port");
+ let port = listener.local_addr().unwrap().port();
+ drop(listener);
+ let descriptor = descriptor(port);
+ let mut child = spawn_matching_listener(port, &descriptor.nonce, true);
+ wait_until_listening(port);
+ let result = stop_orphaned_controller(&descriptor);
+ let exited = child.try_wait().unwrap().is_some();
+ let _ = child.kill();
+ let _ = child.wait();
+ result.expect("stop Controller whose socket closed during cleanup");
+ assert!(exited, "Controller process survived shutdown");
+ }
+
+ #[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 64cfa35cb..2c993c4c7 100644
--- a/crates/launcher/src/main.rs
+++ b/crates/launcher/src/main.rs
@@ -7,6 +7,7 @@ mod desktop_attachment;
mod desktop_path_overrides;
mod installation_layout;
mod native_harness_broker;
+mod restart;
mod runtime_instance;
#[cfg(target_os = "linux")]
mod secure_storage;
@@ -52,13 +53,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")]
@@ -129,7 +131,7 @@ impl Error for UnmanagedDesktopConflict {}
fn usage() {
eprintln!(
- "usage:\n codexhost\n codexhost inspect [--json] [--custom-install ]\n codexhost console\n codexhost update\n codexhost launch [--shim ] [--node ] [--host-runtime ] [--desktop-controller ] [--renderer ] [--pi ] [--custom-install ]\n codexhost broker install|status|stop|uninstall\n codexhost delegate --help\n codexhost harness inspect ...\n codexhost delegate start ...\n codexhost thread send|cancel|read|wait|list ..."
+ "usage:\n codexhost\n codexhost inspect [--json] [--custom-install ]\n codexhost console\n codexhost update\n codexhost restart\n codexhost launch [--shim ] [--node ] [--host-runtime ] [--desktop-controller ] [--renderer ] [--pi ] [--custom-install ]\n codexhost broker install|status|stop|uninstall\n codexhost delegate --help\n codexhost harness inspect ...\n codexhost delegate start ...\n codexhost thread send|cancel|read|wait|list ..."
);
}
@@ -496,11 +498,15 @@ impl LaunchOptions {
}
fn desktop_controller_command(
+ desktop_version: &str,
options: &ResolvedLaunchOptions,
control: &RuntimeControl,
environment: &[(OsString, OsString)],
) -> Command {
let mut command = Command::new(&options.node);
+ if std::env::var("CODEXHOST_DESKTOP_VERSION").map_or(true, |value| value.trim().is_empty()) {
+ command.env("CODEXHOST_DESKTOP_VERSION", desktop_version);
+ }
command
// Keep proxy support without surfacing its experimental API notice.
.arg("--disable-warning=UNDICI-EHPA")
@@ -582,16 +588,15 @@ fn wait_for_controller_ready(
}
fn start_desktop_controller(
+ installation: &DesktopInstallation,
options: &ResolvedLaunchOptions,
control: &RuntimeControl,
environment: &[(OsString, OsString)],
) -> Result> {
startup_trace("spawning Desktop Controller");
- let mut controller = spawn_supervised(&mut desktop_controller_command(
- options,
- control,
- environment,
- ))?;
+ let mut command =
+ desktop_controller_command(&installation.version, options, control, environment);
+ let mut controller = spawn_supervised(&mut command)?;
startup_trace("waiting for Desktop Controller readiness");
match wait_for_controller_ready(&mut controller, Duration::from_secs(120)) {
Ok(()) => {
@@ -748,7 +753,7 @@ fn supervise_desktop(
Duration::from_secs(30),
)?;
startup_trace("Codex Desktop launched");
- let mut controller = start_desktop_controller(options, control, environment)?;
+ let mut controller = start_desktop_controller(installation, options, control, environment)?;
let desktop_pid = desktop.root_snapshot().id;
startup_trace("waiting for Host chain");
if !wait_for_host_chain(desktop_pid, options, Duration::from_secs(30))? {
@@ -782,7 +787,12 @@ fn supervise_desktop(
}
}
if let Some(status) = controller.try_wait()? {
- let _ = desktop.shutdown(Duration::from_secs(2));
+ desktop.shutdown(Duration::from_secs(2))?;
+ desktop.cleanup_escaped(Duration::from_secs(2))?;
+ desktop.disarm_cleanup();
+ if status.success() {
+ return Ok(());
+ }
return Err(
format!("Desktop Controller exited while Desktop was running: {status}").into(),
);
@@ -832,7 +842,8 @@ fn supervise_desktop(
startup_trace("Codex Desktop launched");
let desktop_pid = desktop.id();
wait_for_launched_desktop_ownership(installation, &mut desktop, Duration::from_secs(5))?;
- let mut controller = match start_desktop_controller(options, control, environment) {
+ let mut controller = match start_desktop_controller(installation, options, control, environment)
+ {
Ok(started) => started,
Err(error) => {
let _ = desktop.kill();
@@ -882,6 +893,9 @@ fn supervise_desktop(
}
let _ = desktop.kill();
let _ = desktop.wait();
+ if status.success() {
+ return Ok(());
+ }
return Err(
format!("Desktop Controller exited while Desktop was running: {status}").into(),
);
@@ -1093,8 +1107,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)?;
}
@@ -1130,14 +1144,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()?;
@@ -1230,19 +1237,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()?;
@@ -1305,7 +1306,7 @@ fn run_console_command(action: &str) -> Result<(), Box> {
fn is_launch_command(arguments: &[String]) -> bool {
matches!(
arguments.first().map(String::as_str),
- None | Some(START_MENU_ARGUMENT) | Some("launch")
+ None | Some(START_MENU_ARGUMENT) | Some("launch") | Some("restart")
)
}
@@ -1342,6 +1343,13 @@ fn run(arguments: &[String]) -> Result<(), Box> {
Some("update") => Err("update accepts no arguments".into()),
Some("console") => Err("console accepts no arguments".into()),
Some("launch") => launch(parse_launch_options(&arguments[1..])?, false),
+ Some("restart") => {
+ let options = parse_launch_options(&arguments[1..])?;
+ let resolved = parse_launch_options(&arguments[1..])?.resolve()?;
+ let _ = discover_desktop(resolved.custom_install_root.as_deref())?;
+ restart::stop_managed_runtime()?;
+ launch(options, false)
+ }
Some("open-loopback-url") if arguments.len() == 1 => {
let url = read_bounded_loopback_url(std::io::stdin().lock())?;
validate_loopback_root_url(&url)?;
@@ -1372,11 +1380,16 @@ fn main() -> ExitCode {
let launching = is_launch_command(&arguments);
if launching {
startup_record::begin();
- console::set_presentation(if arguments.first().map(String::as_str) == Some("launch") {
- console::Presentation::Print
- } else {
- console::Presentation::Browser
- });
+ console::set_presentation(
+ if matches!(
+ arguments.first().map(String::as_str),
+ Some("launch" | "restart")
+ ) {
+ console::Presentation::Print
+ } else {
+ console::Presentation::Browser
+ },
+ );
}
match run(&arguments) {
Ok(()) => ExitCode::SUCCESS,
@@ -1678,7 +1691,17 @@ mod tests {
#[test]
fn production_controller_uses_private_node_and_loopback_renderer_cdp() {
let options = resolved_options();
- let command = desktop_controller_command(&options, &runtime_control(), &[]);
+ let command =
+ desktop_controller_command("26.1021.31345", &options, &runtime_control(), &[]);
+ if std::env::var("CODEXHOST_DESKTOP_VERSION").map_or(true, |value| value.trim().is_empty())
+ {
+ assert!(
+ command
+ .get_envs()
+ .any(|(key, value)| key == "CODEXHOST_DESKTOP_VERSION"
+ && value == Some(std::ffi::OsStr::new("26.1021.31345")))
+ );
+ }
assert_eq!(command.get_program(), "/opt/node");
assert_eq!(
command.get_args().collect::>(),
@@ -1949,6 +1972,7 @@ mod tests {
fn controller_receives_only_the_managed_network_environment() {
let options = resolved_options();
let command = desktop_controller_command(
+ "26.1021.31345",
&options,
&runtime_control(),
&[
@@ -2047,7 +2071,8 @@ mod tests {
desktop_controller: PathBuf::from(r"\\?\C:\Program Files\codexhost\controller.mjs"),
..resolved_options()
};
- let command = desktop_controller_command(&options, &runtime_control(), &[]);
+ let command =
+ desktop_controller_command("26.1021.31345", &options, &runtime_control(), &[]);
assert_eq!(
command.get_args().nth(1),
diff --git a/crates/launcher/src/restart.rs b/crates/launcher/src/restart.rs
new file mode 100644
index 000000000..6593aeff6
--- /dev/null
+++ b/crates/launcher/src/restart.rs
@@ -0,0 +1,217 @@
+//! Restart only the runtime authenticated by this installation's descriptor.
+use std::error::Error;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::net::TcpStream;
+use std::thread;
+use std::time::{Duration, Instant};
+
+use codexhost_platform::process_executable_path;
+
+use crate::runtime_instance::{
+ RuntimeDescriptor, default_descriptor_path, default_guard_path, read_descriptor,
+ try_acquire_launcher_guard,
+};
+
+fn request_stop(descriptor: &RuntimeDescriptor) -> Result<(), Box> {
+ let address = format!("127.0.0.1:{}", descriptor.control_port).parse()?;
+ let mut stream = match TcpStream::connect_timeout(&address, Duration::from_secs(2)) {
+ Ok(stream) => stream,
+ Err(error) if error.kind() == std::io::ErrorKind::ConnectionRefused => {
+ return crate::desktop_attachment::stop_orphaned_controller(descriptor);
+ }
+ Err(error) => return Err(error.into()),
+ };
+ let observed = crate::desktop_attachment::matching_controller_process_ids(descriptor)?;
+ stream.set_read_timeout(Some(Duration::from_secs(5)))?;
+ stream.set_write_timeout(Some(Duration::from_secs(2)))?;
+ writeln!(stream, "STOP {}", descriptor.nonce)?;
+ let mut response = String::new();
+ BufReader::new(stream).take(128).read_line(&mut response)?;
+ if response.trim_end() == "rejected" {
+ // Older controllers have no STOP command. Only the process whose command
+ // line matches this descriptor's port and nonce is eligible for shutdown.
+ return crate::desktop_attachment::stop_orphaned_controller(descriptor);
+ }
+ if response.trim_end() != "stopping" {
+ return Err("Controller cannot restart this runtime; fully quit and reopen codexhost once to load the new controller".into());
+ }
+ if crate::desktop_attachment::wait_for_known_controller_exit(
+ descriptor,
+ &observed,
+ Duration::from_secs(2),
+ )? {
+ return Ok(());
+ }
+ crate::desktop_attachment::stop_orphaned_controller(descriptor)
+}
+
+pub(super) fn stop_managed_runtime() -> Result<(), Box> {
+ stop_managed_runtime_at(
+ &default_descriptor_path()?,
+ &default_guard_path()?,
+ &std::env::current_exe()?.canonicalize()?,
+ Duration::from_secs(45),
+ )
+}
+
+fn stop_managed_runtime_at(
+ descriptor_path: &std::path::Path,
+ guard_path: &std::path::Path,
+ expected: &std::path::Path,
+ timeout: Duration,
+) -> Result<(), Box> {
+ let descriptor = read_descriptor(descriptor_path)?.ok_or("No managed Desktop is running")?;
+ if process_executable_path(descriptor.launcher_pid)?.canonicalize()? != expected {
+ return Err("The running Desktop belongs to another codexhost installation".into());
+ }
+ request_stop(&descriptor)?;
+ let started = Instant::now();
+ while started.elapsed() < timeout {
+ if let Some(_guard) = try_acquire_launcher_guard(guard_path)?
+ && read_descriptor(descriptor_path)?.is_none()
+ {
+ return Ok(());
+ }
+ thread::sleep(Duration::from_millis(100));
+ }
+ Err("Managed Desktop did not stop before the restart timeout".into())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::TcpListener;
+
+ #[test]
+ fn stop_authenticates_and_requires_explicit_acknowledgment() {
+ for (reply, accepted) in [
+ ("stopping\n", true),
+ ("ready\n", false),
+ ("failed\n", false),
+ ] {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let descriptor = RuntimeDescriptor::new(
+ 1,
+ listener.local_addr().unwrap().port(),
+ "0123456789abcdef0123456789abcdef".into(),
+ )
+ .unwrap();
+ let server = thread::spawn(move || {
+ let (mut socket, _) = listener.accept().unwrap();
+ let mut line = String::new();
+ BufReader::new(socket.try_clone().unwrap())
+ .read_line(&mut line)
+ .unwrap();
+ assert_eq!(line, "STOP 0123456789abcdef0123456789abcdef\n");
+ socket.write_all(reply.as_bytes()).unwrap();
+ });
+ assert_eq!(request_stop(&descriptor).is_ok(), accepted);
+ server.join().unwrap();
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn acknowledged_stop_escalates_when_controller_closes_socket_but_remains_alive() {
+ use std::process::{Command, Stdio};
+ let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let port = listener.local_addr().unwrap().port();
+ drop(listener);
+ let descriptor =
+ RuntimeDescriptor::new(1, port, crate::runtime_instance::random_nonce().unwrap())
+ .unwrap();
+ let mut command = Command::new("python3");
+ command
+ .args([
+ "-c",
+ "import socket,signal,time,sys\nsignal.signal(signal.SIGTERM,signal.SIG_IGN)\ns=socket.socket()\ns.bind(('127.0.0.1',int(sys.argv[2])))\ns.listen(1)\nprint('ready',flush=True)\nc,_=s.accept()\nline=c.makefile('rb').readline()\nassert line == ('STOP '+sys.argv[4]+'\\n').encode()\nc.sendall(b'stopping\\n')\nc.close()\ns.close()\nwhile True: time.sleep(0.1)",
+ "--attachment-port",
+ &port.to_string(),
+ "--attachment-nonce",
+ &descriptor.nonce,
+ ])
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null());
+ {
+ use std::os::unix::process::CommandExt;
+ command.process_group(0);
+ }
+ let mut child = command.spawn().unwrap();
+ let mut ready = String::new();
+ BufReader::new(child.stdout.take().unwrap())
+ .read_line(&mut ready)
+ .unwrap();
+ assert_eq!(ready.trim(), "ready");
+ let visible = Instant::now();
+ while crate::desktop_attachment::matching_controller_process_ids(&descriptor)
+ .unwrap()
+ .is_empty()
+ {
+ assert!(
+ visible.elapsed() < Duration::from_secs(2),
+ "acknowledging Controller fixture was not visible"
+ );
+ thread::sleep(Duration::from_millis(20));
+ }
+ let result = request_stop(&descriptor);
+ let exited = child.try_wait().unwrap().is_some();
+ let _ = child.kill();
+ let _ = child.wait();
+ result.expect("finish acknowledged Controller stop");
+ assert!(exited, "acknowledging Controller survived shutdown");
+ }
+
+ #[test]
+ fn restart_waits_for_owned_lifecycle_and_refuses_another_installation() {
+ let root = std::env::temp_dir().join(format!(
+ "codexhost-restart-{}",
+ crate::runtime_instance::random_nonce().unwrap()
+ ));
+ std::fs::create_dir(&root).unwrap();
+ let descriptor_path = root.join("runtime.json");
+ let guard_path = root.join("owner.lock");
+ let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let descriptor = RuntimeDescriptor::new(
+ std::process::id(),
+ listener.local_addr().unwrap().port(),
+ "0123456789abcdef0123456789abcdef".into(),
+ )
+ .unwrap();
+ crate::runtime_instance::write_descriptor(&descriptor_path, &descriptor).unwrap();
+ let guard = try_acquire_launcher_guard(&guard_path).unwrap().unwrap();
+ let executable = std::env::current_exe().unwrap().canonicalize().unwrap();
+ assert_eq!(
+ stop_managed_runtime_at(
+ &descriptor_path,
+ &guard_path,
+ &root.join("other"),
+ Duration::from_secs(1)
+ )
+ .unwrap_err()
+ .to_string(),
+ "The running Desktop belongs to another codexhost installation"
+ );
+ let removed = descriptor_path.clone();
+ let server = thread::spawn(move || {
+ let (mut socket, _) = listener.accept().unwrap();
+ let mut line = String::new();
+ BufReader::new(socket.try_clone().unwrap())
+ .read_line(&mut line)
+ .unwrap();
+ socket.write_all(b"stopping\n").unwrap();
+ thread::sleep(Duration::from_millis(100));
+ std::fs::remove_file(removed).unwrap();
+ drop(guard);
+ });
+ stop_managed_runtime_at(
+ &descriptor_path,
+ &guard_path,
+ &executable,
+ Duration::from_secs(2),
+ )
+ .unwrap();
+ server.join().unwrap();
+ std::fs::remove_dir_all(root).unwrap();
+ }
+}
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/crates/platform/Cargo.toml b/crates/platform/Cargo.toml
index 7068e0500..8520d4036 100644
--- a/crates/platform/Cargo.toml
+++ b/crates/platform/Cargo.toml
@@ -6,6 +6,7 @@ rust-version.workspace = true
publish.workspace = true
[dependencies]
+serde_json = "1.0.145"
serde = { version = "1.0.228", features = ["derive"] }
sha2 = "0.10.9"
@@ -17,7 +18,6 @@ nix = { version = "0.31.3", features = ["feature", "fs", "process", "signal", "u
[target.'cfg(target_os = "linux")'.dependencies]
rustix = { version = "1.1.4", features = ["process"] }
-serde_json = "1.0.145"
[target.'cfg(target_os = "macos")'.dependencies]
libproc = "0.14.11"
diff --git a/crates/platform/src/asar_metadata.rs b/crates/platform/src/asar_metadata.rs
new file mode 100644
index 000000000..23d377704
--- /dev/null
+++ b/crates/platform/src/asar_metadata.rs
@@ -0,0 +1,137 @@
+//! Bounded reads of Desktop product metadata; MSIX deployment identity lives elsewhere.
+#[cfg(target_os = "windows")]
+use std::fs::File;
+use std::io::{Read, Seek, SeekFrom};
+#[cfg(target_os = "windows")]
+use std::path::Path;
+
+use serde_json::Value;
+
+const MAX_HEADER_BYTES: u32 = 32 * 1024 * 1024;
+const MAX_PACKAGE_BYTES: u64 = 64 * 1024;
+
+#[cfg(target_os = "windows")]
+pub(crate) fn desktop_product_version(path: &Path) -> Option {
+ read_product_version(&mut File::open(path).ok()?)
+}
+
+fn read_product_version(reader: &mut (impl Read + Seek)) -> Option {
+ let length = reader.seek(SeekFrom::End(0)).ok()?;
+ reader.seek(SeekFrom::Start(0)).ok()?;
+ let mut prefix = [0_u8; 16];
+ reader.read_exact(&mut prefix).ok()?;
+ let word = |offset| {
+ u32::from_le_bytes(
+ prefix[offset..offset + 4]
+ .try_into()
+ .expect("four-byte prefix word"),
+ )
+ };
+ let header_size = word(4);
+ let json_size = word(12);
+ if word(0) != 4
+ || !(8..=MAX_HEADER_BYTES).contains(&header_size)
+ || word(8) != header_size - 4
+ || json_size > header_size - 8
+ {
+ return None;
+ }
+ let data_start = 8 + u64::from(header_size);
+ if data_start > length {
+ return None;
+ }
+ let mut header = vec![0; json_size as usize];
+ reader.read_exact(&mut header).ok()?;
+ let header: Value = serde_json::from_slice(&header).ok()?;
+ let entry = header.get("files")?.get("package.json")?;
+ if entry.get("link").is_some() || entry.get("unpacked").is_some_and(|v| v != false) {
+ return None;
+ }
+ let size = entry.get("size")?.as_u64()?;
+ if size == 0 || size > MAX_PACKAGE_BYTES {
+ return None;
+ }
+ let offset = entry.get("offset")?.as_str()?.parse::().ok()?;
+ let start = data_start.checked_add(offset)?;
+ if start.checked_add(size)? > length {
+ return None;
+ }
+ reader.seek(SeekFrom::Start(start)).ok()?;
+ let mut package = vec![0; size as usize];
+ reader.read_exact(&mut package).ok()?;
+ let package: Value = serde_json::from_slice(&package).ok()?;
+ let version = package.get("version")?.as_str()?;
+ let components: Vec<_> = version.split('.').collect();
+ (version.len() <= 64
+ && components.len() == 3
+ && components
+ .iter()
+ .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())))
+ .then(|| version.to_owned())
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+ use super::*;
+ use serde_json::json;
+ use std::io::Cursor;
+
+ pub(crate) fn archive(entry: Value, package: &[u8]) -> Vec {
+ let header = serde_json::to_vec(&json!({"files":{"package.json":entry}})).unwrap();
+ let header_size = (8 + header.len()).next_multiple_of(4) as u32;
+ let mut bytes = Vec::new();
+ for word in [4, header_size, header_size - 4, header.len() as u32] {
+ bytes.extend_from_slice(&word.to_le_bytes());
+ }
+ bytes.extend_from_slice(&header);
+ bytes.resize(8 + header_size as usize, 0);
+ bytes.extend_from_slice(package);
+ bytes
+ }
+
+ #[test]
+ fn reads_product_version_independently_of_msix_deployment_version() {
+ let package = br#"{"version":"26.1021.31345"}"#;
+ let bytes = archive(json!({"size":package.len(),"offset":"0"}), package);
+ assert_eq!(
+ read_product_version(&mut Cursor::new(bytes)).as_deref(),
+ Some("26.1021.31345")
+ );
+ }
+
+ #[test]
+ fn rejects_oversized_truncated_unpacked_linked_and_out_of_bounds_entries() {
+ let package = br#"{"version":"26.1021.31345"}"#;
+ for entry in [
+ json!({"size":MAX_PACKAGE_BYTES+1,"offset":"0"}),
+ json!({"size":package.len(),"offset":"18446744073709551615"}),
+ json!({"size":package.len()+1,"offset":"0"}),
+ json!({"size":package.len(),"offset":"0","unpacked":true}),
+ json!({"size":package.len(),"offset":"0","link":"elsewhere"}),
+ ] {
+ assert!(read_product_version(&mut Cursor::new(archive(entry, package))).is_none());
+ }
+ let mut bytes = archive(json!({"size":package.len(),"offset":"0"}), package);
+ bytes[4..8].copy_from_slice(&(MAX_HEADER_BYTES + 1).to_le_bytes());
+ assert!(read_product_version(&mut Cursor::new(bytes)).is_none());
+ assert!(read_product_version(&mut Cursor::new(b"not an asar")).is_none());
+ }
+
+ #[test]
+ fn rejects_invalid_product_metadata_and_four_part_package_versions() {
+ for package in [
+ b"{}".as_slice(),
+ b"invalid",
+ br#"{"version":"26.1021.3705.0"}"#,
+ br#"{"version":"26..31345"}"#,
+ ] {
+ assert!(
+ read_product_version(&mut Cursor::new(archive(
+ json!({"size":package.len(),"offset":"0"}),
+ package
+ )))
+ .is_none()
+ );
+ }
+ }
+}
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/platform/src/installation.rs b/crates/platform/src/installation.rs
index 5a8d2ecb4..5eb689c06 100644
--- a/crates/platform/src/installation.rs
+++ b/crates/platform/src/installation.rs
@@ -303,7 +303,8 @@ fn windows_installation(
appx_activation: Some(details.appx_activation),
},
build: details.version.clone(),
- version: details.version,
+ version: crate::asar_metadata::desktop_product_version(&asar_path)
+ .unwrap_or(details.version),
asar_integrity: sha256_file(&asar_path)?,
install_root,
desktop_launcher: desktop_executable.clone(),
@@ -444,8 +445,9 @@ pub fn discover_codex_desktop() -> Result {
///
/// A portable/unpacked installation has no registered AppX package, so the
/// version is not available from the Windows PackageManager. The official MSIX
-/// embeds the marketing version in the `` attribute of
-/// its manifest; fall back to a placeholder when it cannot be parsed.
+/// embeds its deployment version in the `` attribute of
+/// its manifest; fall back to a placeholder when it cannot be parsed. Product
+/// metadata comes from app.asar.
#[cfg(target_os = "windows")]
fn portable_package_version(package_root: &Path) -> Option {
let content = std::fs::read_to_string(package_root.join("AppxManifest.xml")).ok()?;
@@ -509,7 +511,9 @@ pub fn discover_codex_desktop_from_root(
}
let executable_codex_cli = packaged_codex_cli.clone();
- let version = portable_package_version(&package_root).unwrap_or_else(|| "0.0.0.0".to_owned());
+ let build = portable_package_version(&package_root).unwrap_or_else(|| "0.0.0.0".to_owned());
+ let version =
+ crate::asar_metadata::desktop_product_version(&asar_path).unwrap_or_else(|| build.clone());
Ok(DesktopInstallation {
identity: DesktopIdentity::WindowsPackage {
@@ -517,7 +521,7 @@ pub fn discover_codex_desktop_from_root(
package_family_name: format!("{WINDOWS_CODEX_PACKAGE_NAME}_portable"),
appx_activation: None,
},
- build: version.clone(),
+ build,
version,
asar_integrity: sha256_file(&asar_path)?,
install_root: package_root,
@@ -670,6 +674,53 @@ mod windows_tests {
(root, install_root)
}
+ #[test]
+ fn msix_product_metadata_does_not_replace_deployment_identity() {
+ let (root, install_root) = portable_fixture(
+ "codexhost-windows-msix-metadata",
+ Some(br#""#),
+ );
+ let package = br#"{"version":"26.1021.31345"}"#;
+ fs::write(
+ install_root.join("app/resources/app.asar"),
+ crate::asar_metadata::tests::archive(
+ serde_json::json!({"size":package.len(),"offset":"0"}),
+ package,
+ ),
+ )
+ .expect("write metadata fixture");
+ let portable = super::discover_codex_desktop_from_root(&install_root).unwrap();
+ assert_eq!(portable.version, "26.1021.31345");
+ assert_eq!(portable.build, "26.1021.3705.0");
+ let cache_root = root.join("LocalAppData");
+ let cli = cache_root.join("OpenAI/Codex/bin/test/codex.exe");
+ fs::create_dir_all(cli.parent().unwrap()).unwrap();
+ fs::write(cli, b"cached cli").unwrap();
+ let activation = WindowsAppxActivationIdentity {
+ package_full_name: "OpenAI.Codex_26.1021.3705.0_x64_family".into(),
+ app_user_model_id: "OpenAI.Codex_family!App".into(),
+ };
+ let registered = windows_installation(
+ WindowsPackageDetails {
+ package_name: "OpenAI.Codex".into(),
+ package_family_name: "OpenAI.Codex_family".into(),
+ appx_activation: activation.clone(),
+ version: "26.1021.3705.0".into(),
+ install_root,
+ },
+ &cache_root,
+ )
+ .unwrap();
+ assert_eq!(registered.version, "26.1021.31345");
+ assert_eq!(registered.build, "26.1021.3705.0");
+ assert!(
+ matches!(registered.identity, DesktopIdentity::WindowsPackage {
+ appx_activation: Some(identity), ..
+ } if identity == activation)
+ );
+ fs::remove_dir_all(root).unwrap();
+ }
+
#[test]
fn custom_root_discovers_a_portable_installation_without_a_registered_package() {
let (root, install_root) = portable_fixture(
diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs
index 12ab780d2..a7b32954a 100644
--- a/crates/platform/src/lib.rs
+++ b/crates/platform/src/lib.rs
@@ -5,6 +5,8 @@ use std::fmt::{self, Display, Formatter};
use std::io;
use std::path::{Path, PathBuf};
+#[cfg(any(target_os = "windows", test))]
+mod asar_metadata;
#[cfg(target_os = "windows")]
#[allow(unsafe_code)]
mod background;
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/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/PRIVACY.md b/docs/PRIVACY.md
index a76443f50..3bb1cdd21 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -32,4 +32,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/external-thread-steering.md b/docs/architecture/external-thread-steering.md
index 1f2ddde23..0e4d002c3 100644
--- a/docs/architecture/external-thread-steering.md
+++ b/docs/architecture/external-thread-steering.md
@@ -77,9 +77,11 @@ Host 和 Renderer 接入必须配套发布;仅升级 Host、让旧 Renderer
## 跟进消息队列
-Codex Desktop **26.903.61454 / build 8378** 在功能开关及 app-server 版本满足条件时使用服务端队列。运行中追加消息会先调用 `thread/queue/list`,再调用 `thread/queue/add`;仅让 Host 返回空列表不能恢复入队。外部 Thread 不属于官方 app-server,Host 对这些请求的拒绝仍需保留,不能透传或伪造服务端队列。
+Codex Desktop **26.903.61454 / build 8378** 起,在功能开关及 app-server 版本满足条件时使用服务端队列。运行中追加消息会先调用 `thread/queue/list`,再调用 `thread/queue/add`。Desktop **26.1016** 及之后的 Manager 带有 `getStreamRole` 时,`renderer-external-queue.ts` 不再把外部 Thread 从 `serverQueue.isEnabled` 里排除,因此跟进消息走 Host 的 `thread/queue/*`。Host 按官方列表信封返回 `{ data, nextCursor }`。队列里的文本项写成 Desktop `type: "text"`(`input_text` 入队时也转成该类型),并带有 `text_elements` 数组(缺省 `[]`),供 Desktop **26.1021.31345 / build 21828** 读取 `text_elements.length`。空的 `turn/start` 会消费 Host 队列中匹配或队首的跟进文本;队列为空时仍返回 `-32602`。官方 Codex Thread 仍使用原生 app-server 队列。
-`renderer-external-queue.ts` 在当前 Manager 的 `getTurnCoordinator().serverQueue.isEnabled(threadId)` 上排除外部 Thread,让 Desktop 继续使用保留的本地队列:
+Avatar overlay 是独立的 Desktop webcontents,在语音、IAB 或 Computer Use 之后可以成为该 Thread 的 stream owner。主窗口的 Renderer Binding 不会注入 overlay;`cpa-live-voice-hook.js` 在 overlay 里发现 RequestManager 后安装 steer 包装,并把 External Thread 的 stream role 视为 owner,使 follower 窗口可以把跟进消息写入 Host `thread/queue/*`。Overlay 作为 owner 通过 `thread/queue/start` 执行队列。缺少 type:text 的 `turn/start` 会从 Host 队列或本地队列条目恢复文本。
+
+没有 `getStreamRole` 的 Desktop 上,`renderer-external-queue.ts` 仍在当前 Manager 的 `getTurnCoordinator().serverQueue.isEnabled(threadId)` 上排除外部 Thread,让 Desktop 继续使用保留的本地队列:
- 只读取同一个 Manager 的 `getConversation(threadId)`,核对 Thread ID 及 Host 投影的保留标记 `modelProvider: "codexhost"`。不根据当前 Agent 选择或 Model 名称猜测,不跨 Host 缓存身份,也不增加 RPC。
- 普通入队、编辑、重排、删除/恢复、暂停和终态后的自动执行仍由 Desktop 原有队列负责,不新增 Host 队列或 Harness 命令。
@@ -112,6 +114,10 @@ Codex Desktop **26.924.22138 / build 11645** 的本地队列在 `turnCompleted`
- `packages/renderer-extension/test/renderer-external-steering.test.ts`:正常 start 占位、唯一输入、无旧轮 steer Item、去重、队列暂停恢复、owner/follower、失败和卸载。
- `packages/renderer-extension/test/renderer-external-steering-rpc.test.ts`:类方法的 RPC 可访问性、模型/权限列表读取、官方与外部 steer、实例隔离、卸载和重复安装,以及卸载期间待定请求的清理。
- `packages/renderer-extension/test/renderer-external-queue.test.ts`:经生产 Adapter 安装路径验证外部入队、已有暂停消息、队列清空后再次入队、同连接官方队列、Manager 隔离、元数据更新、旧版后端和卸载恢复。
+- `packages/renderer-extension/test/renderer-overlay-external-followups.test.ts`:avatar overlay 发现 RequestManager 后把 External Thread 视为 stream owner。
+- `packages/renderer-extension/test/renderer-external-steering.test.ts`:缺少 type:text 的 queued `turn/start` 从本地队列恢复文本。
+- `packages/host-runtime/test/app-server-host.follow-up-queue.test.ts`:Host `thread/queue/add` 与 `thread/queue/start`,空 `turn/start` 消费队列,以及中断恢复后空队列仍返回 `-32602`。
+- `packages/host-runtime/test/external-thread-follow-up-queue.test.ts`:入队项补齐 `text_elements`,并把 `input_text` 规范为 `type: "text"`。
- `packages/renderer-extension/test/versioned-renderer-adapter.test.ts`:现有版本化绑定与清理回归。
- `packages/host-runtime/test/app-server-host.manual-compaction.test.ts`:命令菜单与输入 `/compact` 两条路径在压缩 `item/started` 前恰好发送一次通知,普通 Turn 与非 `/compact` 命令 Turn 的自动压缩均不发送。
- `packages/renderer-extension/test/renderer-manual-compaction.test.ts`:经窗口 `mcp-notification` 消息的外部 Thread 登记,follower、未加载/官方 Thread、其他 Host、其他 frame 与非法消息跳过,缺少 Desktop 绑定、卸载,以及按 Host 连接(含 SSH)安装。
diff --git a/docs/architecture/harness-executable-discovery.md b/docs/architecture/harness-executable-discovery.md
index eb74b2771..b3ae1c2bb 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/architecture/harness-plugin-architecture.md b/docs/architecture/harness-plugin-architecture.md
index 85f7c1fef..8c245418c 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) 只回收 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/architecture/harness-plugin-runtime.md b/docs/architecture/harness-plugin-runtime.md
index 74dffdbe9..f57b5d939 100644
--- a/docs/architecture/harness-plugin-runtime.md
+++ b/docs/architecture/harness-plugin-runtime.md
@@ -141,6 +141,10 @@ Codex 的会话用量解析已从公共协议层迁入 `codex-usage`,账户配
**修改需要重启 codexhost。** 已创建的 Adapter 与 Session 不热替换;`restartRequired` 比较当前持久化值与此 Host 构造时的值。仅刷新连接状态不会应用新路径。设置页填写应用安装目录,例如 `D:\program\WorkBuddy`,不要求用户定位 `.exe` 或脚本。WorkBuddy Adapter 定位 `WorkBuddy.exe` / `WorkBuddy AI.exe` / `WorkBuddyAI.exe` 及同目录内置脚本。目录布局不完整时检查失败,不借用其他安装的文件,也不回退到 PATH 或默认安装。底层保留原有文件入口覆盖兼容能力。注册表自动发现不在本功能范围内。
+## Grok Computer Use 设置
+
+本地 Host 的 Grok 连接详情和未运行时的控制台连接页提供 Computer Use 开关。`codexhost/harness/grok-computer-use/get·set` 读写 `~/.codexhost/grok-computer-use.json`,(`enabled` 与 `browser` 两个独立开关)。Computer Use 需要 `cua_node` 与 Sky;In-app browser 需要 `cua_node` 与 `@oai/browser-desktop`,不依赖 Sky。在 Desktop 为外部 Harness 提供授权与会话绑定之前,`browser` 只保存意向,不写入 `browser` 表面。写入先改 Grok config,再写 preference;失败时返回 `applyFailed`。ChatGPT 设置中的 Computer use / Browser 仍只作用于官方 Codex。新的 Grok 线程读取该开关;已打开的线程不热加载 MCP。
+
## Harness CLI 版本与更新
设置 → 连接,在已安装 Harness 的右侧详情展示「Harness CLI 版本」、当前版本、最新版本与更新按钮。进入连接页或切换 Host 时,自动检查当前 Host 列表中已安装且不在安装、检测中的 Harness,无须先选择详情;诊断完成后符合条件的 Harness 也会自动检查。有新版本时,列表操作区显示灰色「有更新」小字,悬停展示当前与最新版本;支持自动更新时在小字右侧提供更新按钮,点击后才调用该 Host 的原生更新能力,更新期间禁用重复操作。仅支持手动更新的安装保留「有更新」小字及详情中的原安装方式提示。不提供手动检查更新按钮;后台诊断刷新和切换列表不会丢弃该页按 Host/Harness 保存的检查结果或重复启动更新。重新打开连接页后再次自动检查。所选 Harness 的详情卡片标题旁提供官网外链图标,列表行及详情正文不重复提供通用官网或安装说明链接。未安装且有自动安装入口时显示下载图标;点击显式发起 CLI 安装,点击行本身查看手动安装命令、复制按钮、接入提示和「重新检测」,正文不提供额外的通用安装指南跳转入口。WorkBuddy 在详情正文提供一个「下载」按钮,跳转官网首页,并提示下载安装桌面应用,不提供分系统的安装指南链接。未支持版本管理能力的插件、旧 Host 和 Broker 路径显示不可用,不回退更新本机的另一份安装。
diff --git a/docs/harnesses/antigravity/antigravity-subagents.md b/docs/harnesses/antigravity/antigravity-subagents.md
index 17de3095a..afe42ab83 100644
--- a/docs/harnesses/antigravity/antigravity-subagents.md
+++ b/docs/harnesses/antigravity/antigravity-subagents.md
@@ -20,7 +20,7 @@ Validated with agy 1.1.27 on Windows:
The Antigravity Adapter emits the existing `subagentDelegation`,
`subagent.state.changed`, and `subagent.transcript.changed` contracts.
The common Host creates a read-only child Thread and the common projector emits
-`collabAgentToolCall`. No Antigravity-specific Renderer or Host RPC is added.
+`subAgentActivity` for Desktop’s native read-only Subagents panel. No Antigravity-specific Renderer or Host RPC is added.
The creation Item completes when the native spawn call completes. Its child
remains running until the separate native status confirms otherwise. During an
diff --git a/docs/harnesses/capability-boundaries.md b/docs/harnesses/capability-boundaries.md
index 33ecb7e9c..52a658f60 100644
--- a/docs/harnesses/capability-boundaries.md
+++ b/docs/harnesses/capability-boundaries.md
@@ -9,6 +9,10 @@
- **CodeBuddy**:从 ACP 动态目录读取斜杠命令,按原生压缩事件及持久化结果确认上下文压缩;通过原生附加系统提示让会话发现 Host 委派 CLI。Fork 和修订通过无模型的原生复制、原生 `/fork` 与仅作用于新副本的 rollback 完成,校验完整历史前缀与源会话不变;派生配置随原生引用恢复。成功派生后通过同一临时 ACP 进程的原生 HTTP 接口删除中间副本;早期失败或强杀仍可能残留,详见专属文档。详见 [CodeBuddy 接入](codebuddy/codebuddy-harness-integration.md)。
- **Cursor**:参数化模型目录提供 Thinking 组合;ACP 原生命令目录提供命令;原生 shell 通过 Host 安装的 `codexhost-delegation` skill 调用 CLI 向外委派。详见 [Cursor 接入](cursor/cursor-cli-experimental.md)。
- **Hermes**:会话统一使用官方 Gateway,不以版本数值差异阻断,接入提问、Thinking、命令、压缩、原生 Diff,以及未压缩历史的独立 Fork/修订;通过进程内注册的私有临时 Skill 发现 Host CLI 并保留父任务环境,不向共享技能目录写入临时 Skill。不再保留 ACP 实现或 SDK 依赖;历史发现和导入使用原生 SessionDB 只读查询,导入的原生身份通过 Gateway 恢复。详见 [Hermes 能力与边界](hermes/hermes-capabilities.md)。
+- **Grok**:本 fork 使用补丁版 **grokgod Grok build**,补丁清单见本机 `~/.grokgod/src/docs/patch-inventory.md`,不能把依赖补丁的行为视为 stock Grok 能力。
+ - MCP 同意表单通过 `_x.ai/mcp/elicit` 投影为 Desktop 审批(Allow once / Deny)。Always allow 依赖 grokgod 补丁 `0025-mcp-elicitation-persist`:原生 MCP persist 选项经 ACP 转发,接受后的范围作为 MCP `_meta.persist` 回传;Host 仅在请求包含 `always` 时显示该选项。未打补丁的 Grok 不提供该持久化往返,只有 Allow once / Deny。Allow this conversation 在原生 session 生命周期核实前不提供。仅支持无字段或已核验的空 object schema;URL elicitation、任意输入表单和原生 TUI elicitation 卡片均不提供。工具 `requestPermission` 仍走独立路径。详见 [Grok MCP elicitation](grok/mcp-elicitation.md)。
+ - 原生 `ask_user_question` 已有于 Grok;grokgod 补丁 `0011-ask-question-timeout-action` 增加可配置的超时选择和基于活动的超时重置,并非 Desktop 提问接入。Host ACP 客户端的扩展请求处理器只实现 `_x.ai/mcp/elicit`,对 `_x.ai/ask_user_question` 返回 `Method not found`。用户报告的 grokgod ACP 实测错误为 `Failed to reach the client for user question: "Method not found": _x.ai/ask_user_question`,因此 Desktop 不出现提问卡片,工具调用收到错误。补丁不会补齐缺失的客户端方法;接入仍需在 Grok 插件内映射原生请求/应答,不能复用审批或 MCP elicitation 的形状。回合取消有独立的已实现路径。
+ - Computer Use 使用用户安装的 `cua_repl` MCP,依赖 grokgod 补丁 `0026-cua-turn-end-release` 提供原生回合边界清理及 Mac Sky 原生释放桥,并需要已安装的 ChatGPT Computer Use runtime;未证明 stock Grok 具备同等行为。CodexHost 连接页分别保存 Computer Use 与 In-app browser(IAB)意向;当前 Host 固定关闭 IAB 绑定,等待 Desktop 的外部 Harness 授权及会话所属的浏览器路由。ChatGPT 设置中的 Computer use / Browser 仍只作用于官方 Codex。详见 [Grok Computer Use setting](grok/computer-use-settings.md)。
以上实现归各 Harness 插件所有,不在公共 Adapter、Host 或 Renderer 中添加 Harness 专用分支。跨插件参考实现不等于共用原生协议;各自保留配置确认、取消、持久化和权限语义。
diff --git a/docs/harnesses/grok/computer-use-release.md b/docs/harnesses/grok/computer-use-release.md
new file mode 100644
index 000000000..f441d87e4
--- /dev/null
+++ b/docs/harnesses/grok/computer-use-release.md
@@ -0,0 +1,43 @@
+# Grok Computer Use release
+
+Grok uses its user-installed `cua_repl` MCP. Computer Use release requires grokgod patch `0026-cua-turn-end-release` on Grok Build `2bdd1d6a` (1.0.45). An unpatched Grok CLI keeps the MCP connection alive without notifying it when a turn ends. CodexHost does not install `computer-use@openai-bundled` into Grok.
+
+## Turn lifecycle
+
+The native Grok Session actor awaits cleanup on successful and failed completion, cancellation (including rewind), and Session close. Completion cleanup runs after claiming the exact turn finalization lease and before replying to the ACP prompt. Cancellation cleanup runs after aborting the matching task. Stale completions cannot release a successor turn.
+
+Cleanup calls the hidden `cua_repl.turn_ended` tool through the existing MCP connection:
+
+```json
+{
+ "hook_event_name": "Stop",
+ "session_id": "",
+ "turn_id": ""
+}
+```
+
+Cancellation uses `Interrupt`; completed Subagent turns use `SubagentStop`. Model tool calls carry the same identities in MCP `_meta["x-codex-turn-metadata"]`, together with their call ID. The cleanup tool is excluded from the model's tool catalog.
+
+## macOS Sky bridge
+
+The installed `cua_node` runtime advertises `turn_ended`, but its Mac Sky service does not release native control on that notification alone. Resetting the REPL or closing the MCP is also insufficient. Grokgod adds a narrow trusted-service wrapper when the configured server is named `cua_repl`, enables the `computer` surface, and uses `@oai/sky/service`.
+
+The wrapper delegates ordinary Sky requests and error classification to the installed service. After `turn_ended`, the host calls the wrapper's release RPC over the same MCP. It sends Sky's `ComputerUseIPCCodexTurnEndedRequest` through the installed trusted native-pipe transport, with `{ threadID: session_id, turnID: turn_id }`. Sky then rejects further Computer Use in that turn; a later turn can use it again.
+
+The wrapper is materialized at `/computer-use/sky-release-service.mjs`. The child MCP's trusted-service environment selects it and adds the exact wrapper file to trusted code paths; user configuration files are unchanged. Other configured trusted services are preserved. The wrapper uses the installed Sky package and IPC version `CodexComputerUseIPC-5`; incompatible or custom Sky services fail closed.
+
+## Failure and limits
+
+- The entire cleanup has a three-second deadline. A missing `turn_ended` prevents Computer Use tool admission. A tool error, protocol error, or timeout disables that MCP connection; restart the Session after fixing the runtime.
+- Successful cleanup preserves the MCP process and REPL bindings. App approvals and Always allow keep their existing native elicitation behavior; see [MCP elicitation](mcp-elicitation.md).
+- Release follows native turn boundaries. There is no additional idle timer during a running turn. Sky's default 120-second IPC timeout bounds individual requests and is separate from turn cleanup.
+- Abrupt process termination, OS failure, and independently running sessions are outside the awaited cleanup path. Sky owns its overlay and input enforcement; CodexHost does not draw a replacement overlay.
+- The Mac bridge depends on an installed private Sky transport contract. Windows and Linux use the MCP notification path and require their own live acceptance checks.
+
+## Verification
+
+Focused native MCP tests cover notification payloads, connection reuse, hidden-tool admission, turn metadata, Mac bridge ordering, missing tools, protocol errors, and timeout. Live Mac contract probes use Clock and verify that Sky rejects a read in an ended turn with “Computer Use is unavailable because the current turn ended.” JavaScript bindings remain available after release.
+
+The full patched-Grok Clock-through-ACP check and visible overlay disappearance remain pending: Sky reported that the user explicitly stopped Computer Use during live verification. The direct native contract check proves that ended-turn control is rejected, but does not by itself prove the full ACP path or overlay disappearance.
+
+Session-specific build and ACP acceptance evidence is recorded in the wiki work item `projects/codex/work/2026-10-21-grok-computer-use-release/`.
diff --git a/docs/harnesses/grok/computer-use-settings.md b/docs/harnesses/grok/computer-use-settings.md
new file mode 100644
index 000000000..0638e60d1
--- /dev/null
+++ b/docs/harnesses/grok/computer-use-settings.md
@@ -0,0 +1,79 @@
+# Grok Computer Use and in-app browser settings
+
+CodexHost enables Grok's ChatGPT-backed `cua_repl` surfaces from two independent switches. Nobody edits `~/.grok/config.toml` by hand.
+
+## Surfaces
+
+- **Codex Desktop → Settings → Connections → Grok** and **codexhost console → Connections** show one card with two switches: **Computer Use** and **In-app browser**. The Harness list and Harness CLI version block stay as they are.
+- Both switches write `~/.codexhost/grok-computer-use.json` (`{ "enabled": boolean, "browser": boolean }`; a file with only `enabled` reads as `browser: false`) and upsert `[mcp_servers.cua_repl]` in `~/.grok/config.toml`.
+- RPC `codexhost/harness/grok-computer-use/set` and `POST /api/harnesses/grok-computer-use` accept `{ enabled }`, `{ browser }`, or both.
+- CodexHost does not inject `computer-use@openai-bundled`. The stanza uses ChatGPT's `cua_node`, `cua-repl` and the same trusted-service names as the official plugin.
+
+## Switch matrix
+
+| Computer Use | In-app browser | `CUA_REPL_ENABLED_SURFACES` | Trusted services | `BROWSER_USE_AVAILABLE_BACKENDS` |
+| --- | --- | --- | --- | --- |
+| off | off | stanza `enabled = false` | empty JSON object (inert) | unset |
+| on | off | `computer` | `sky` | unset |
+| on | on, binding unsupported | `computer` | `sky` | unset |
+| off | on, binding unsupported | stanza `enabled = false` | empty JSON object (inert) | unset |
+
+Computer-only users keep the computer-only stanza. The in-app browser switch stores intent in `~/.codexhost/grok-computer-use.json` even while ChatGPT Desktop refuses Grok IAB. Until Desktop authorizes an external Harness, CodexHost does not add the `browser` surface.
+
+Availability is three separate facts: saved intent (`enabled` / `browser`), installed files (`available` / `browserFilesPresent`), and usable binding (`browserAvailable`). File presence is not IAB success.
+
+The current implementation sets `GROK_BROWSER_BINDING_SUPPORTED = false`. Both `browserAvailable` and the generated Grok configuration use that policy flag. The console's authorization explanation describes the known unsupported integration; reading settings does not perform a live Desktop authorization check. Restarting Desktop or creating a new Grok thread does not change this policy.
+
+Computer Use needs the shared `cua_node` runtime, the packaged Codex CLI, and `Codex Computer Use.app`. The browser switch needs that same runtime plus `@oai/browser-desktop`; it does not need Sky.
+
+Saves and session applies acquire filesystem locks for both the preference directory and Grok config directory, so separate Host and console processes re-read intent inside the same critical section. Grok config is written first, then the preference file. A failed publication attempts to restore the previous config and records `applyFailed` without accepting the new intent. New Grok threads stop before transport startup if applying settings fails. Locks time out after five seconds without stealing another writer’s lock; an abandoned `.grok-computer-use.lock` or `.codexhost-cua.lock` directory requires removal after confirming that its writer has stopped.
+
+## IAB, Computer Use and Chrome extension
+
+| Path | What it drives | Owner |
+| --- | --- | --- |
+| In-app browser (IAB) | ChatGPT Desktop's built-in browser pane through `cua.getBrowser({ id: "iab" })` / `createBrowserTab` | Grok `cua_repl` `browser` surface, opt-in here. Official Codex uses the same backend through ChatGPT **Settings → Browser** |
+| Computer Use | Mac apps through Sky (`Codex Computer Use.app`) | Grok `cua_repl` `computer` surface, opt-in here. ChatGPT **Settings → Computer use** governs official Codex |
+| Chrome extension | A user's Chrome profile | Official Codex only. Not wired to Grok and out of scope here |
+
+The official Browser Use helpers are never intercepted (see [macOS native tools](../../platforms/macos/macos-native-tools.md)). Grok gets its own `cua_repl` process.
+
+## Default and availability
+
+- Users who never opted in stay off. An enabled legacy `cua_repl` stanza without a surface list reads as Computer Use on, browser off. An explicit surface list preserves separate Computer Use and browser intent; a browser-only stanza never opts into Computer Use.
+- Computer Use needs ChatGPT.app `cua_node`, `cua-repl`, the packaged Codex CLI, and `Codex Computer Use.app`.
+- The browser switch also needs `cua_node/lib/node_modules/@oai/browser-desktop`. Without it the switch cannot be turned on.
+- New Grok threads read the switches. An open thread does not reload MCP.
+
+## Fail-closed binding
+
+ChatGPT Desktop serves IAB through per-session unix sockets and checks each caller:
+
+1. The MCP call must carry `x-codex-turn-metadata.session_id` equal to a Desktop session with a captured browser route.
+2. The native pipe verifies the code-signing identity of the connecting process and its parent chain (OpenAI `com.openai.codex*` identities).
+
+When either check fails, `cua.getBrowser({ id: "iab" })` reports `Browser is not available: iab` and Grok does not open a tab. CodexHost does not synthesize session ids, disable peer authorization, or reuse another backend as IAB evidence.
+
+## Verified on Desktop 26.1016.51333
+
+A real Grok ACP session invoked a read-only diagnostic MCP tool and its own browser-only `cua_repl` server. The diagnostic received no `x-codex-turn-metadata`: session and turn metadata were absent, an effective session could not be compared with the native Grok session, and Desktop route ownership was unverified. The subsequent Grok IAB selection was unavailable. Existing Desktop logs also contain the native peer rejection reason `missing-code-signing-identity`; that log observation is separate from metadata captured by the diagnostic tool and does not prove route registration.
+
+In an official Codex thread, the official Browser Use tool successfully bound IAB, opened a blank test tab, read its state and closed it. The Grok probe’s before/after snapshot found `~/.codex/config.toml`, `auth.json` and `AGENTS.md` byte-identical. No official Browser Use helper was intercepted or reconfigured. Computer Use remains an independent surface.
+
+`browserAvailable` stays false, including when `CODEXHOST_GROK_IAB_BINDING` is inherited. There is no environment override that qualifies the blocked binding. Sanitized evidence is retained under ignored `.codexhost/update-impact/26.1016.51333/`.
+
+## Qualification on Desktop 26.1016.62342
+
+On macOS, Grok `1.0.45` (`2bdd1d6a6369`) sends `x-codex-turn-metadata` on model tool calls when the MCP server is named `cua_repl`. A real ACP session using a read-only, session-local diagnostic confirmed nonempty session, turn and call identities. The session identity matched the native Grok session, and the awaited `turn_ended` call matched the same session and turn. A diagnostic registered under a different server name does not establish the metadata behavior of `cua_repl`.
+
+A separate real ACP session supplied the installed browser-only `cua_repl` through its session MCP configuration, without changing global browser preferences or removing native authorization. Browser APIs were exposed, but selecting IAB returned `Browser is not available: iab`. During that probe window Desktop logged six native socket rejections with reason `missing-code-signing-identity`. The installed `cua_node` Node and `node_repl` executables have OpenAI signatures; the installed Grok executable is unsigned. The rejection reason does not identify the exact failed ancestry check.
+
+This establishes a native authorization blocker after the Host configuration gate is removed for a bounded diagnostic. It does not establish an authorized browser route for the Grok session. Desktop's current bundle retains separate peer authorization and session-route ownership checks; metadata forwarding alone cannot satisfy either check. A supported external caller authorization and session-registration contract is still required before enabling normal Grok browser sessions.
+
+Sanitized metadata and browser-probe summaries are retained under ignored `.codexhost/update-impact/26.1016.62342/`. They contain presence/equality booleans and rejection reasons rather than raw session identities or transcripts. Actual Grok navigation, turn cleanup of browser resources, and isolation between two browser-enabled Grok sessions remain unverified because binding failed.
+
+## Limits
+
+- The in-app browser switch is intent until Desktop supports external authorization and a session-owned browser route. CodexHost does not synthesize session ids, disable peer authorization, or treat another backend as IAB evidence.
+- The Grok session id differs from the CodexHost thread id, so even an authorized peer needs a Desktop session route for the id Grok sends.
+- Chrome extension and Excel stay out of scope.
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/harnesses/grok/mcp-elicitation.md b/docs/harnesses/grok/mcp-elicitation.md
new file mode 100644
index 000000000..6ea221262
--- /dev/null
+++ b/docs/harnesses/grok/mcp-elicitation.md
@@ -0,0 +1,27 @@
+# Grok MCP elicitation
+
+CodexHost Grok sessions project bounded MCP consent forms as Desktop approvals. This is how a user-installed Computer Use MCP (`cua_repl`) can ask to use Clock or another app.
+
+## Wire
+
+Grok sends ACP extension `x.ai/mcp/elicit`. The JSON-RPC method on the wire is `_x.ai/mcp/elicit`. The Grok Adapter handles it through `Client.extMethod`. Initialization keeps `clientCapabilities: {}`; this Grok revision does not gate elicitation on a capability flag.
+
+Supported request: `sessionId`, `toolCallId`, `serverName`, `message`, `mode: "form"`, and either no `requestedSchema` or a reviewed empty object schema. URL mode and non-empty/unreviewed schemas cancel without granting access.
+
+Native replies are `{ outcome: "accept", content: {} }`, `{ outcome: "decline" }`, or `{ outcome: "cancel" }`.
+
+## Desktop
+
+An in-turn form becomes a Host approval with Allow once and Deny. Host Runtime labels the Desktop request with the Harness name (`Grok`); the Adapter title and description keep the MCP server name and consent text.
+
+Allow once maps to native accept. Desktop deny or UI dismiss maps to native decline. Turn cancellation, Session close, process fault, and native `interaction_resolved` (`tool_call_id`) map to native cancel and close the Desktop card once.
+
+When the patched Grok offer includes `persist: ["always"]` (or `session` plus `always`), Desktop adds **Always allow**. That maps to `{ outcome: "accept", content: {}, persist: "always" }`. Allow once still omits persist. **Allow this conversation** is not shown: native `session` lifetime is not proven to match a Grok conversation.
+
+Unpatched Grok (no persist field) keeps Allow once / Deny only. Auto and Always approve permission modes do not auto-accept elicitation.
+
+## Limits
+
+- User-installed `cua_repl` remains Grok configuration. CodexHost does not inject `computer-use@openai-bundled`.
+- Arbitrary data-entry forms, URL elicitation, and native TUI elicitation-card parity are unsupported.
+- Tool `requestPermission` stays on its own nested ACP response path.
diff --git a/docs/harnesses/grok/subagent-status-and-model.md b/docs/harnesses/grok/subagent-status-and-model.md
index 1f75413ae..eb5655106 100644
--- a/docs/harnesses/grok/subagent-status-and-model.md
+++ b/docs/harnesses/grok/subagent-status-and-model.md
@@ -2,7 +2,7 @@
## Scope
-The Grok Adapter maps native spawn/task tools to the existing Host `subagentDelegation` contract. Desktop receives the existing `collabAgentToolCall` projection and remains responsible for its Subagent list, collapse behavior, navigation, and visual presentation.
+The Grok Adapter maps native spawn/task tools to the existing Host `subagentDelegation` contract. Desktop receives native `subAgentActivity` Items and remains responsible for its Subagent list, collapse behavior, navigation, and visual presentation.
This integration does not inject a replacement list, custom status subtitles, avatars, or a history panel. It does not inspect React Fiber, add child `thread/read` requests, or scan and rewrite official Codex protocol frames. Native Codex Subagent behavior is unchanged.
@@ -13,15 +13,23 @@ This integration does not inject a replacement list, custom status subtitles, av
- Child transcripts are read-only snapshots of the child Native Session; they are not separate writable Host Sessions.
- Parent Native history is projected back into Subagent Items when the Thread is reopened. Presentation uses the existing Desktop history path rather than a Renderer-maintained copy of the list.
+## Read-only native presentation
+
+Desktop uses `collabAgentToolCall` with `tool: "spawnAgent"` to mark a child as interactive. `canAcceptDirectInput: false` alone does not select its read-only panel in the inspected Desktop build. External native children therefore project as `subAgentActivity`, which selects Desktop's existing Subagents panel and read-only conversation. Native Codex protocol traffic and independent writable external Threads do not pass through this projection.
+
+Each receiver gets a stable activity identity, including multi-child operations. Spawn operations retain a `started` activity for native descendant discovery; send operations use `interacted`. Observed terminal states add a separate terminal activity, preserving the spawn identity during live updates and history replay. The native activity vocabulary represents successful completion as `completed` and failed or interrupted execution as `interrupted`. Per-child Thread status remains driven by native lifecycle observations. Prompt and result text remain in Harness history and child transcripts rather than being encoded into an activity's display name.
+
## Model and reasoning effort
The public `HostSubagentState` has optional `model` and `reasoningEffort` fields. Adapters must omit unknown values rather than infer them from parent Session settings.
Grok supplies the raw child Model ID when explicitly present in spawn arguments or a native spawned event. A reported child Model supersedes the spawn argument. An explicit argument describes the requested child configuration; it is not independent verification of the inference backend. Grok does not currently supply independently observed child reasoning effort, so that field is omitted.
-The protocol projector passes these values in separate native `model` and `reasoningEffort` fields without formatting an ID into a display label. Since a native collaboration Item has only one configuration slot, a multi-child Item supplies it only when all children have the same configuration. Missing or heterogeneous configurations are not guessed. Adapters that omit both optional fields retain the previous null projection.
+The Host persists each child's reported `model` and `reasoningEffort` as child metadata and exposes them on child Thread reads and resumes. The native Subagents header reads these Thread fields. A multi-child operation keeps each child's own configuration. Missing observations preserve previously reported child values; a child with no observations exposes null values, including legacy mappings that copied a parent transport selection. New child mappings use an unconfigured Harness route and never copy parent model, permission, or thinking selections. The metadata lives outside the strict core record so existing core record readers remain compatible.
+
+Claude Code's normal `Agent` and legacy `Task` tools preserve an explicit child `model` argument through lifecycle updates and completion. Raw aliases such as `haiku` and `sonnet` describe the requested configuration. A tracked child's native assistant response Model supersedes that argument, including when the child inherited its Model. Synthetic response Models are ignored. Omitted, blank, or `inherit` arguments remain unknown until a child reports its Model; the Adapter does not copy the parent's Model. `SendMessage` does not change child configuration. Persisted live observations survive reopening, but cold Claude history projected as ordinary Tool Items does not backfill Model metadata for legacy children.
-Whether and where those fields are visible depends on the installed Desktop version; this integration does not promise a custom `status · Model · effort` subtitle.
+Desktop formats the raw Model ID and supported reasoning effort in its native header; the Host does not invent display names or resolve a local alias into an inference backend. No custom `status · Model · effort` subtitle is injected.
## Summary panel discovery and restored identity
@@ -37,8 +45,7 @@ the child transcript has never been opened. After Host restart, an unobserved
child remains `notLoaded` until a real status observation is available.
Live projection and parent history hydration reuse the same child Host Thread
-identity. Historical collaboration Items carry their parent Host Thread ID and
-resolved child receiver IDs, rather than raw CLI-native IDs. This also recovers
+identity. Historical activity Items carry resolved child Host Thread IDs rather than raw CLI-native IDs. This also recovers
children from history recorded before the Host had materialized them. Existing
child mappings retain their IDs; concurrent hydration and live events cannot
create duplicate children for one parent Native Session and native child ID.
@@ -69,4 +76,4 @@ New Grok Sessions receive an explicitly requested startup Model through the nati
## Validation scope
-Focused automated tests cover tool/event mapping, lifecycle projection, child transcript reads, history replay, explicit versus unknown child Model metadata, startup arguments, unchanged user input, native protocol projection for missing and heterogeneous configurations, rollback child/descendant identity rebinding, in-flight read retirement, reused native IDs, fork isolation, and indexed/legacy metadata lookup costs. These checks do not replace live Grok/Desktop validation; the removed custom UI screenshot is not evidence for this native-only version.
+Focused automated tests cover tool/event mapping, lifecycle projection, child transcript reads, history replay, explicit versus unknown child Model metadata, startup arguments, unchanged user input, per-child configuration persistence, legacy unknown configuration, parent isolation, native read-only activity projection for multiple receivers, rollback child/descendant identity rebinding, in-flight read retirement, reused native IDs, fork isolation, and indexed/legacy metadata lookup costs. These checks do not replace live Grok/Desktop validation. Live Reveal and header appearance still require a manual check in the running Desktop; no GUI acceptance is claimed from protocol tests alone.
diff --git a/docs/index.md b/docs/index.md
index f533ae8de..b5f71f845 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -30,7 +30,7 @@
## Harness 专项
-[能力表边界](harnesses/capability-boundaries.md)记录各 Harness 的接入条件、Kimi 动态 Thinking、Cursor 的单向委派,以及 Pi 权限模式、Antigravity 审批与压缩等剩余缺口。
+[能力表边界](harnesses/capability-boundaries.md)记录各 Harness 的接入条件、Kimi 动态 Thinking、Cursor 的单向委派,Pi 权限模式、Antigravity 审批与压缩,以及 Grok MCP 同意表单等剩余缺口。
### Antigravity
@@ -92,6 +92,10 @@
| 文档 | 内容与阅读时机 |
| --- | --- |
| [`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. |
+| [`harnesses/grok/mcp-elicitation.md`](harnesses/grok/mcp-elicitation.md) | Grok MCP consent forms project to Desktop approval, including offered Always allow; bounded empty forms only. |
+| [`harnesses/grok/computer-use-release.md`](harnesses/grok/computer-use-release.md) | Grok native turn-end Computer Use cleanup, Mac Sky bridge, failure behavior, and runtime limits. |
+| [`harnesses/grok/computer-use-settings.md`](harnesses/grok/computer-use-settings.md) | Grok `cua_repl` Computer Use and in-app browser (IAB) switches, surface matrix, IAB vs Computer Use vs Chrome extension, fail-closed binding. |
| [`harnesses/grok/permission-modes.md`](harnesses/grok/permission-modes.md) | Grok 会话内权限切换、原生生效确认、失败处理与验证范围。 |
### Copilot
@@ -110,12 +114,13 @@
| 文档 | 内容与阅读时机 |
| --- | --- |
-| [`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 认证或账号路由时阅读。 |
| [`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) | Shared Desktop/console Voice settings, audited native Chat lifecycle and external Harness voice: new-chat selection, shared task routing, configured gateway, lifecycle limits, focused smoke tests, attended voice verification, and daily Sparkle pin drift (`.github/workflows/compat-daily.yml`). |
## 平台、进程与远程运行
@@ -139,6 +144,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 versioning and upstream-sync runbook: dirty-work backup, merge authorization, voice/IAB/compat-daily preservation, Desktop re-audit, and origin-only publishing. |
| [`NOTICE.md`](NOTICE.md) | Web 控制台打开或刷新时读取的项目公告,含显示开关、标题、类型与正文;默认关闭,发布规则见控制台文档。 |
| [`operations/repository-maintenance.md`](operations/repository-maintenance.md) | PR 标签、CI 评论和发布前检查自动化;修改仓库自动化时阅读。 |
diff --git a/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md b/docs/operations/codex-desktop-upgrade-diagnosis-playbook.md
index 30ade5e67..6a1399456 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.
+
## 一、先建立分层模型
### 历史原生会话不能拖入引用或通过 `@` 搜索会话
@@ -328,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):
@@ -454,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 0417e4d4c..821602ee0 100644
--- a/docs/operations/codexhost-console.md
+++ b/docs/operations/codexhost-console.md
@@ -2,11 +2,17 @@
控制台是一个本地网页,不依赖 Codex Desktop 启动成功。左侧导航分为:
-- **总览**:当前状态与下一步操作、codexhost 版本 / Codex Desktop 版本 / 安装方式三个信息框、最近一次启动的结果;失败时说明卡在哪一步和错误原文,内部阶段时间线折叠在“详细信息”中;注入持续失败时显示原因。成功时不展示时间线,不显示启动历史表格。原启动诊断页已合并,旧的 `#diagnostics` 和 `?view=diagnostics` 链接显示总览。日志不在页面中展示,通过“导出诊断包(含日志)”获取。
-- **设置**:连接、远程连接、账号、会话导入、更新,与 Codex 设置页使用同一套页面和 Host 接口。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。
+- **总览**:当前状态与下一步操作、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 接口。**语音**与 Desktop 的独立语音页共享路由选择和注入设置,使用明确的应用与重启操作。连接页支持通过运行中的本地 Host 安装 Harness CLI、检查版本和更新,复用安装进度、错误提示及安装后检测;不支持远程 Host 安装。WorkBuddy 仍通过官网手动下载安装桌面应用。Host 不可用时不提供一键安装。
- **其他**:关于。
-外观(思考文本显示、空闲释放)由 Codex 界面保存,只能在 Codex 设置页修改。设计背景见 [`proposals/独立 Web 控制台方案.md`](../proposals/独立%20Web%20控制台方案.md)。
+Appearance settings (thinking text, usage warnings and idle release) remain Desktop-only. **Voice** is shared between Desktop and the console on this Mac. Chat selects ChatGPT or Codex; Codex connection selects Desktop default or configured gateway. Codex Work independently selects Desktop default or gateway; external Harness voice stays on Host → gateway. Existing saved choices migrate once without replacing a newer shared edit.
+
+The Voice page uses drafts, Cancel and **Apply changes**. Injection changes use **Apply and restart codexhost** with interruption confirmation. Activation is reported separately from saving; a missing Desktop acknowledgment remains unknown or pending. Detected active calls block Apply. **Advanced** contains integration and environment-override details; **Gateway → Configure** reveals manual setup guidance for the local Codex configuration, without displaying credentials. The console remains usable with Host stopped, including saving settings for a later launch. Narrow windows use a compact navigation menu instead of horizontal scrolling.
+
+The overview restart action remains available for recovery. A different Launcher and ready Controller are required for restart completion; the Voice page additionally checks loaded injection and applied route acknowledgment. See [voice routing and configuration](cpa-live-voice-desktop-hook.md) for contracts and constraints.
+
+When the selected Grok or Claude Code launcher is managed by grokgod or clawgod, the connection page reads the installed CLI version with background updates disabled. It identifies the patch manager, shows the latest patched version as unknown, and leaves updates to that manager's updater or installer. A version check does not invoke the stock updater through these launchers or compare a patched installation against official releases. Grok Thinking selections use the native ACP `_meta.reasoningEffort` field, alongside `_meta.contextWindow` when selected.
## 项目公告
@@ -21,7 +27,7 @@ title: 版本兼容性提醒
type: warning
---
-这里填写公告正文,可使用 **粗体**、列表和 [链接](https://github.com/BytePioneer-AI/codex-host)。
+这里填写公告正文,可使用 **粗体**、列表和 [链接](https://github.com/karlorz/codex-host)。
```
- `enabled`:仅 `true` 显示,`false` 隐藏;正文为空也隐藏。
@@ -126,12 +132,13 @@ Launcher 启动的本地 Host Runtime 在 `127.0.0.1` 的随机端口开放控
- codexhost 运行中:通过 Host 的更新流程检查与安装,与 Codex 设置页一致。
- codexhost 未运行:控制台下载并准备更新,拉起 Updater 并等待身份校验后的就绪确认才退出;Updater 等待控制台进程退出,再按原流程安装并重新启动 codexhost。
+- Windows 手动安装包使用 Windows Restart Manager 关闭占用本安装包文件的 codexhost Runtime/Launcher 进程,并且不会重新启动旧的控制台;这样直接运行下载的安装包时不需要先手动结束 codexhost。
- npm 安装需通过 npm 命令启动的控制台(`codexhost`、`codexhost console`,或 Codex 设置页)才能更新,因为更新需要 npm 路径环境变量。
- 源码构建不支持在控制台更新。
## 连接页(codexhost 未运行时)
-codexhost 未运行时,“连接”页列出已安装的插件(读取 `manifest.json`,不执行插件代码)及是否启用,并可修改接受自定义安装路径的插件的路径。路径与 Codex 设置页共用 `<数据目录>/harness-launch-settings/<插件>.json`;codexhost 运行中修改时,在重新启动 codexhost 后生效。插件目录与 Host Runtime 一致:发布包的 `app/plugins`,以及 `CODEXHOST_PLUGIN_DIRECTORY` 或 `<数据目录>/plugins`。
+codexhost 未运行时,“连接”页列出已安装的插件(读取 `manifest.json`,不执行插件代码)及是否启用,并可修改接受自定义安装路径的插件的路径。路径与 Codex 设置页共用 `<数据目录>/harness-launch-settings/<插件>.json`;codexhost 运行中修改时,在重新启动 codexhost 后生效。Grok 另有 Computer Use 与 In-app browser 两个开关,与设置页共用 `<数据目录>/grok-computer-use.json`,并写入 `~/.grok/config.toml` 的 `cua_repl`;新的 Grok 线程生效。插件目录与 Host Runtime 一致:发布包的 `app/plugins`,以及 `CODEXHOST_PLUGIN_DIRECTORY` 或 `<数据目录>/plugins`。
## 诊断包与 Issue
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..e8282fb39
--- /dev/null
+++ b/docs/operations/cpa-live-voice-desktop-hook.md
@@ -0,0 +1,1127 @@
+# CPA live-voice Desktop hook
+
+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**, **26.1016.51333**, **26.1016.61520**, **26.1016.62342**, and **26.1021.31345** use the native client-owned-call flow described below. Native Chat Instant Connect has a separate **26.1021.31345** profile.
+
+For upstream merges, follow the [Live Voice preservation checklist](fork-versioning.md#preserve-live-voice-when-syncing-upstream), including regression checks and separate Desktop-version qualification.
+
+## Default
+
+Desktop and the console expose the same **Voice** page using the existing settings components. Voice is separate from General/Appearance. Choose **Chat → Voice experience → ChatGPT or Codex**, then **Codex connection → Desktop default or Configured gateway** when Codex is selected. **Work → Voice connection** independently chooses Desktop default or gateway for Codex Work. External Harness voice remains Host → configured gateway, including when Codex Work selects Desktop default.
+
+The local console owns `~/.codexhost/live-voice-inject-v1.json`: version 1 retains `inject` and optionally stores `routes` with `chatExperience`, `chatConnection` and `workConnection`. Desktop reaches this service through the local Host's bounded `codexhost/voice/settings` bridge; it does not open a browser or use cross-origin browser requests. Desktop legacy preferences migrate once, only while shared routes are absent. A console edit takes precedence over later migration. Atomic publication preserves the existing Controller reader. Revision checks reject stale edits from another window.
+
+ChatGPT is the default Chat experience; its native START and Instant Connect prewarm remain owned by Desktop's browser-voice stack. Codex/Desktop default restores the original Codex voice entry and passes `/wham` through. Codex/gateway forwards call creation through Host. Explicit Chat launch ownership takes precedence over remembered Work/Harness selection. Work defaults to gateway; saved choices are preserved.
+
+Edits are drafts until **Apply changes**. **Cancel** restores the loaded snapshot. Desktop polls shared choices every two seconds and acknowledges its applied revision; the page reports pending or unknown activation until that acknowledgment. A validated browser cache restores the last applied route before the first Host poll on a new document. Route changes are deferred during a detected active call, and fresh Desktop call reports block setting writes and Voice-page restarts. Runtime reports expire after 12 seconds; an absent report is unknown, not evidence that no call exists.
+
+**Advanced → Enable voice integration** defaults on. Changing injection uses **Apply and restart codexhost**, with explicit task/call interruption confirmation. Environment `CODEXHOST_CPA_LIVE_VOICE=0|1` controls injection for that launch and locks its saved toggle. The Controller's loaded flag is compared with the desired effective setting; saving and restart completion alone never claim configuration activation. Restart failures retain the saved configuration and permit retry. The normal launch action attaches to an already running Desktop.
+
+Gateway configuration details are collapsed under **Configuration → Gateway → Configure**. This reveals setup guidance, not a credential editor. The readiness check returns only a boolean from the Host; it does not probe a live call or expose provider credentials. Narrow console layouts use a compact expandable navigation menu and stacked preference controls without a horizontal navigation scrollbar.
+
+The 26.1013 Work/Chat routing choices apply on the next call. The injection setting and process environment require restart. Host call creation rereads the selected provider and realtime URLs from `CODEX_HOME/config.toml`; Desktop-owned config consumers can require restart. End an existing call before changing its gateway configuration so cleanup uses the correct gateway.
+
+**Desktop default** leaves Renderer call creation on Desktop's service; it does
+not clear the native backend's `experimental_realtime_*` URL overrides. If those
+still point at a gateway, native Work can create its call on Desktop and then
+attempt to attach through the gateway, producing a WebSocket 404. With gateway
+URLs configured, select **Configured gateway** for Codex Work to align creation
+and attachment. Returning to the official backend also requires restoring the
+native realtime configuration and restarting. The Voice page reports three facts separately: **Gateway configuration** (parsed
+credentials), **Preferences applied** (Desktop acknowledged the saved routes),
+and **Desktop gateway voice integration** (the version-qualified gateway transport hook). The
+**Configured** status checks configuration and credential presence only, not
+call connectivity. An unsupported hook means this Desktop build is outside the
+audited allowlist; it does not mean the gateway settings were lost.
+
+Desktop **26.1016.61520** hides native Start voice when Statsig rollout
+`3683713372` is on and `model_provider` is not `openai`. CodexHost leaves
+`model_provider = "custom"` unchanged. Work + gateway (and owned `codexhost/`
+Harness composers) project only that composer’s `voice.realtime` /
+`voice.thread` subscriptions. ChatGPT Chat stays on its separate native browser-voice stack.
+
+The unaudited-Desktop asset gate still fail-closes. `~/.codex/config.toml` realtime keys stay manual.
+
+Daily CI (`npm run compat:daily`, `.github/workflows/compat-daily.yml`) compares the public Sparkle appcast to the Work/gateway and Chat voice pins. It does not download ChatGPT.app. A pin behind Sparkle latest opens or comments on a `compat-broken` issue on this repository.
+
+First-start official-backend recovery is covered by `packages/host-runtime/test/native-account-host.test.ts` (failed first generation, delayed second generation, no `unhandledRejection`). Manual cold-start UAT: fully quit ChatGPT and CodexHost, start **CodexHost 0.13.3** from the menu bar, wait ~15s. Pass: no 无法载入组织设定, console 总览 stays 启动成功, Work + Grok still shows Start voice. Fail: org-settings dialog on first launch only, then OK after 重试 — that is the remaining Host-wait gap, not Chat voice.
+
+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"
+```
+
+## Desktop 26.1021.31345 qualification
+
+Work/gateway client-owned-call is audited for **26.1021.31345** build **21828**. `/wham/realtime/calls` is `Htr.getInstance().fetch` on `app-initial-feb30b196d11.js`; the message bridge is shared `bIt` and services `Qot`. Overlay is `avatar-overlay-native-page-d549db3706f1.js`. The 26.1016.62342 and 26.1016.61520 Work profiles remain supported.
+
+The same version profile supplies every Work voice consumer, including new-chat
+launch routing and the overlay starter. Capability initialization uses initial
+`l0n`, capability family `c0n`, and inference-gate family `Cnr`; the 26.1016
+profiles retain `njr` / `tjr` / `uBr`. Family resolution accepts arbitrary cache
+keys, so resolving `3683713372` on an unrelated family does not identify the
+inference gate. Missing audited exports fail closed without probing other
+families. Both composer and overlay project only their owned subscriptions.
+Existing Codex chats send the native product experience `codex`; Work home
+starts send `work`. Both use the Work connection choice for explicit local
+composer starts. The starter and call transport retain that native product
+identity, including when the overlay has no Composer or model metadata.
+Starter qualification continues to exclude remote, global-hotkey and
+avatar-button requests.
+
+Composer health also compares the projected subscriber and instance snapshots
+with React's committed capability value. A brief Host readiness or routing
+interruption can commit the native fallback without changing its underlying
+atom. When readiness returns, a stale committed value causes the next
+one-second poll to rebind and notify the owned subscriptions. React's equivalent
+lazy snapshot wrappers remain valid; account and workspace checks remain native.
+Work surface discovery ignores retained zero-size, hidden or inert Chat composers.
+A visible Chat composer still prevents that Work fallback from claiming the surface.
+
+When Desktop omits resource timing and script asset names, the explicit Desktop
+product version and platform select the profile and the hook imports its exact native assets.
+Observed incomplete or mixed initial/shared pairs still fail closed. Overlay
+preparation rechecks that profile and requires the committed starter component
+to belong to its exported native overlay page.
+
+A controlled live check on 25 October 2026 confirmed one enabled **Start new
+voice chat** button in an idle Codex Work composer with the gateway selected.
+The native capability remained false while the owned subscription read true.
+The separate overlay check confirmed gateway readiness through `bIt` and a
+committed projected starter snapshot, then restored the native subscription.
+Neither check opened a call or captured microphone audio; physical microphone,
+gateway call creation, audible reply and hangup acceptance remain unverified on
+26.1021. The primary and overlay bundles must both be installed; replacing an
+overlay hook after its native starter has registered does not replace that
+existing callback. A fresh overlay document or Desktop restart binds it.
+
+Native Chat Instant Connect uses the full composer START relocated to
+`entity-business-profile-reference-b68063dfc05d.js` (`i`, initializer `r`). Its
+scope/props/metadata/abort-signal signature preserves native navigation,
+availability and session guards. `start-voice-5bbdc12fe620.js` provides a
+different starter-prompt entry and cannot substitute for composer START.
+`renderer-native-chat-voice-assets.ts` owns the version profiles and normalizes
+audited exports to the retained Chat lifecycle contract. Native initializers run
+before their mutable exports are captured.
+
+The current Chat profile uses shared `Bst` / `XVt` / `KNt` for AppScope, React
+and ReactDOM; initial `CQn` / `PXn` / `tdn` / `qXt` for RouteScope,
+ComposerScope, backend Chat ID and browser voice store; primary `um` / `lm` for
+browser initialization and its enable flag; and
+`voice-prewarm-d672f1245523.js` for the native prewarm controller. Draft
+selection retains the native location/navigation/transcript/intent roles via
+initial `Xkn` / `yAn` / `ydn` / `fdn` / `R2` and shared `Fot`.
+
+A live no-call check confirmed one enabled Chat waveform immediately before
+Send, matching size and vertical alignment, and one native prewarm controller.
+Microphone capture, native call creation, audible reply and hangup are separate
+acceptance checks.
+
+### Native Voice Settings with a custom provider
+
+cc-switch's Unified Codex session history intentionally uses `model_provider =
+"custom"` for official-subscription and third-party sessions. Keep that provider
+identity and its history. Changing it to `openai` is not a voice repair.
+
+The native Voice Settings page reads the `voice.realtime`, `dictation.composer`
+and `dictation.global` capability family members. Its Voice chat group also reads
+a derived boolean, and the page and shortcut controls read the native dictation
+mode selector. On 26.1021.31345, CodexHost restores the native controls by
+projecting only these owned Settings subscriptions when the voice access policy
+is `allowed`, root voice capability is ready and capable, and readonly replay
+proves the inference-provider condition is the sole blocker. The Voice chat boolean replay must read that exact realtime capability and agree
+with its native value before substitution. Dictation mode replay preserves the
+native platform choice (`os-global` on macOS/Windows, `app` on eligible Linux)
+and all non-provider dictation policy and feature checks. Native atoms, provider configuration and global Statsig values
+retain their values. Account/workspace denial, loading, errors, rollout gates
+and unrelated restrictions remain authoritative.
+
+`renderer-voice-settings-availability.ts` identifies committed Settings groups
+below the exported page and binds the boolean reader. The existing capability binder handles only the explicitly selected family
+members on the exported Settings page. `renderer-dictation-settings.ts` binds
+the exact native mode selector within Settings, including newly mounted shortcut
+controls, so their save handlers select the correct OS service. Ambiguous ownership or unsupported
+subscriptions fail closed, with rollback if a required reader could not be bound.
+Navigation, disposal and eligibility changes restore native readers. While a
+supported binding is unavailable, the existing text-only explanation can identify
+a provider-only rejection; it does not grant access. The hook retries controls
+after passive subscription initialization without repeatedly rewriting that text.
+The installer also retries native asset discovery during document startup: an
+initial chunk observed before its shared chunk cannot permanently skip Settings
+installation. Incomplete or mixed asset pairs still fail closed.
+
+The restored controls keep their native handlers:
+
+| Control | Native persistence and consumers | Verification limit |
+| --- | --- | --- |
+| Voice | Account `voice_name`, through `/settings/account_user_setting`; catalog from `/settings/voices` in `wingman` mode. Native Chat browser voice consumes the account voice. Work realtime resolves the same catalog's effective slug, including native alias mapping, and puts it into session audio output. The gateway bootstrap preserves that session body; external Harness voice accepts the native voice parameter. | Picker opens with nine compatible voices. Saving a different voice and audible selection require attended acceptance. |
+| Language | Native account `voice_main_language` preference, shared with voice and dictation. Visibility follows the native dictation capabilities or available dictation mode. | Language selection is visible; the existing saved language is retained. |
+| Dictation | Native composer/global dictation capabilities and mode selector. The macOS shortcut editor uses the native global-dictation service and retains permission setup. | Shortcut row is visible and its owned mode readers commit `os-global`. Capture, transcription, OS registration and permission grants are not established by restoring Settings. |
+| Voice chat hotkey | Desktop `commandKeybindings` service, command `realtimeVoice`; its main-process controller starts `global_hotkey_new_thread` through the avatar overlay. | Editor is enabled. Registration and call launch retain native main-process and overlay capability checks. The gateway starter hook qualifies explicit local composer starts, not global-hotkey starts; restoring Settings does not establish global-hotkey call support with a custom provider. |
+| Screen context | Native `realtimeVoiceScreenContextEnabled` preference. The row retains macOS, appshot availability and dynamic-tool configuration checks, plus the native first-use introduction and OS permissions. Work's `capture_screen_context` handler checks this preference again and requires the matching active voice task. | Row and toggle are enabled in the observed eligible environment. Actual foreground capture and permissions were not exercised. Chat browser voice has separate camera/screen-share controls; this toggle does not establish equivalent capture there. |
+
+Live checks on this build confirmed the native controls, an account policy of
+`allowed`, and a native `voice.realtime` capability that stayed false while the
+owned Settings readers rendered the controls. Language and Dictation also retain
+their native capability values while the owned mode readers commit the platform
+mode. Three Settings navigation round
+trips retained the controls. No account voice, hotkey or permission was changed.
+The Composer capability hook reports `native-ineligible` when its provider-only
+replay still cannot make the native capability capable; this is diagnostic state
+and leaves the native account, workspace and rollout checks authoritative.
+These checks do not establish a complete spoken call. On 25 October 2026,
+Karl reported that the instructed manual Voice Settings and existing-Codex-chat
+voice test passed after a full restart with the startup and `codex` request
+fixes installed. This is operator-reported acceptance; the global voice hotkey,
+screen capture, dictation and separate Chat browser-voice call still require
+their own live checks.
+
+| Asset | 62342 | 26.1021.31345 |
+| --- | --- | --- |
+| Initial | app-initial-dddef47d6ef6.js | app-initial-feb30b196d11.js |
+| Shared | app-shared-fc3316d48427.js | app-shared-2cae01273bae.js |
+| Work overlay | avatar-overlay-native-page-ef8332c6a2fb.js | avatar-overlay-native-page-d549db3706f1.js |
+| Native Chat START | start-voice-7cbf0a39d53b.js | entity-business-profile-reference-b68063dfc05d.js |
+| Native Chat prewarm | voice-prewarm-5d107129ac1c.js | voice-prewarm-d672f1245523.js |
+
+The 26.1021.31345 ASAR SHA-256 is
+`130145706a912db74e4d260ad7538f10f1e05e88af955f9afba42cdf01e64606`.
+Sanitized evidence is under ignored `.codexhost/update-impact/26.1021.31345/`.
+
+## Windows MSIX product and platform qualification
+
+Windows discovery reads the Desktop product version from a bounded ASAR
+`package.json` entry and retains the MSIX deployment version in `build` and its
+activation identity. The Launcher supplies that product version to the Controller;
+the voice bootstrap includes `desktopPlatform`. `CODEXHOST_DESKTOP_VERSION` remains
+a diagnostic override. Missing metadata falls back to the deployment version,
+which cannot qualify a different product profile by numeric resemblance.
+
+Product `26.1021.31345` has separate macOS and Windows asset profiles. A complete,
+unambiguous pair identifies a profile; empty resource lists require an exact
+product/platform match. Mixed assets, partial pairs, unknown versions and platform
+contradictions fail closed. Windows uses the public prewarm wrapper, which
+initializes and re-exports the native Instant Connect controller.
+
+Profile selection does not grant account/workspace access or prove a spoken
+call. Native Windows Settings excludes screen context; codexhost preserves that
+platform restriction. Voice selection and hotkey controls remain subject to native
+account and workspace eligibility. The daily Windows CI job verifies MSIX and
+profile fixtures, independently of the macOS Sparkle latest-version check.
+
+On 26 October 2026, the user reported that both Chat and Work voice-chat paths
+passed manual testing on the Windows VM after completing official login. Before
+login, the configured gateway was ready but native provider-only capability
+replay remained ineligible. This acceptance resolves that observed login-related
+blocker while preserving the custom provider route and native eligibility checks.
+The report does not separately qualify Voice Settings controls, screen context,
+external Harness voice, or long-call recovery.
+
+## Live voice-hook debug on a running Desktop
+
+Use this procedure when a voice control is missing on an already running
+codexhost-controlled Desktop after a hook source fix. Live `Runtime.evaluate`
+updates the current document. Composer visibility can be confirmed without a
+Desktop restart. A later manual restart remains the user's choice for loading
+new document-start source. This is an attended debug path; it is not a generic
+hot-reload contract for the Renderer binding.
+
+For native Chat, use the same attach/build/evaluate procedure with
+`installNativeChatVoiceHook` and `__codexhostNativeChatVoiceV1.dispose()`;
+`activeCall` must be false before disposal. The Settings controls and fallback explanation have their
+own `installVoiceSettingsExplanation` / `__codexhostVoiceSettingsV1` lifecycle.
+Verify the actual Chat button and native Settings controls after refresh, not just hook
+installation. Both installers are included in the persistent Renderer bundle.
+
+Read-only attach and status (steps 1–2) come first. Run mutation steps within
+the user's requested debug/install scope; preserve any manual-restart
+preference. Skip mutation during an active or unknown call.
+
+### 1. Attach through repository CDP
+
+Discover the current loopback HTTP origin from the running Controller
+`--renderer-cdp-endpoint` or the Desktop `--remote-debugging-port`. The Launcher
+runtime descriptor identifies the current process tree; extract only that origin.
+Leave ports, PIDs, minified export names, temporary paths, nonce, tokens, query
+and hash values, and whole target lists out of notes and command output.
+
+Attach with `CdpClient` from `packages/desktop-control/dist/index.js` (or
+`dist/cdp-client.js`). `listCdpTargets(origin)` reads loopback `/json/list`.
+`selectPrimaryRendererTarget` requires exact `app://-/index.html` with empty
+search and hash. Overlay targets are distinct URLs that include `avatar-overlay`
+(`selectAvatarOverlayRendererTargets`).
+
+```js
+import { execFileSync } from "node:child_process";
+import {
+ CdpClient,
+ listCdpTargets,
+ selectAvatarOverlayRendererTargets,
+ selectPrimaryRendererTarget,
+} from "./packages/desktop-control/dist/index.js";
+
+function discoverRendererCdpOrigin() {
+ const text = execFileSync("/bin/ps", ["-axo", "command="], { encoding: "utf8" });
+ let fromDesktop;
+ for (const line of text.split("\n")) {
+ const endpoint =
+ /--renderer-cdp-endpoint\s+(http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+)\b/.exec(line);
+ if (endpoint) return endpoint[1];
+ const port = /--remote-debugging-port=(\d+)/.exec(line);
+ if (port && /ChatGPT|Codex\.app|codexhost/i.test(line)) {
+ fromDesktop = `http://127.0.0.1:${port[1]}`;
+ }
+ }
+ if (fromDesktop) return fromDesktop;
+ throw new Error("Running Desktop has no loopback Renderer CDP origin");
+}
+
+async function evaluate(client, expression) {
+ const response = await client.command("Runtime.evaluate", {
+ expression,
+ awaitPromise: true,
+ returnByValue: true,
+ });
+ if (response?.exceptionDetails) {
+ throw new Error(
+ response.exceptionDetails.exception?.description ??
+ response.exceptionDetails.text ??
+ "Runtime.evaluate failed",
+ );
+ }
+ return response?.result?.value;
+}
+
+const origin = discoverRendererCdpOrigin();
+const targets = await listCdpTargets(origin);
+const primary = selectPrimaryRendererTarget(targets);
+const overlay = selectAvatarOverlayRendererTargets(targets)[0] ?? null;
+if (!primary) throw new Error("No primary app://-/index.html target");
+```
+
+Define `SANITIZED_STATUS` from step 2 before running the attach example there.
+`evaluate` allows an `undefined` value, which is the normal `Runtime.evaluate`
+result for an IIFE that returns nothing. `CdpClient.evaluate` still checks
+`exceptionDetails` but requires a returned value, so use this helper for
+injection. Connect `overlay?.webSocketDebuggerUrl` only for steps 5–6.
+
+### 2. Inspect sanitized status only
+
+Evaluate counts and booleans. Keep config, credentials, prompts, transcripts,
+IDs, and the DOM out of the result.
+
+```js
+const SANITIZED_STATUS = `(() => {
+ const visible = (el) => {
+ const box = el.getBoundingClientRect();
+ return box.width > 0 && box.height > 0 && !el.closest("[hidden], [inert]");
+ };
+ const nameOf = (el) =>
+ (el.getAttribute("aria-label") || el.textContent || "").replace(/\\s+/g, " ").trim();
+ const routes = JSON.parse(localStorage.getItem("codexhost.voice-routes-cache.v1") || "null");
+ let localComposer = false;
+ try {
+ const routing = window.__codexhostHostRoutingV1;
+ const route = routing?.forComposer();
+ localComposer = route?.hostId === "local" && routing?.forHost("local") === route;
+ } catch {}
+ const workComposers = [...document.querySelectorAll("[data-codex-composer-root]")].filter(
+ visible,
+ );
+ const workComposer = workComposers.length === 1 ? workComposers[0] : null;
+ const start = workComposer
+ ? [...workComposer.querySelectorAll("button")].filter((el) => {
+ const name = nameOf(el);
+ return (
+ visible(el) &&
+ !el.disabled &&
+ /^(Start new voice chat|Start voice chat|Start voice)$/i.test(name)
+ );
+ })
+ : [];
+ return {
+ desktopVersion: window.__codexhostCpaLiveVoiceHookV1?.desktopVersion ?? null,
+ clientOwned: window.__codexhostClientOwnedVoiceV1?.status ?? null,
+ externalLaunch: window.__codexhostExternalVoiceLaunchV1?.status ?? null,
+ voiceCapability: window.__codexhostVoiceCapabilityV1?.status ?? null,
+ voiceStartCapability: window.__codexhostVoiceStartCapabilityV1?.status ?? null,
+ gatewayReady: window.__codexhostVoicePreferenceStateV1?.gatewayReady?.get(window) === true,
+ workGateway: routes?.workConnection === "gateway",
+ chatGateway: routes?.chatExperience === "codex" && routes?.chatConnection === "gateway",
+ chatgptChat: routes?.chatExperience === "chatgpt",
+ localComposer,
+ visibleWorkComposers: workComposers.length,
+ visibleChatComposers: [...document.querySelectorAll("[data-chatgpt-composer]")].filter(visible)
+ .length,
+ enabledStartVoiceButtons: start.length,
+ stopPresent: [...document.querySelectorAll("button")].some((el) => {
+ const name = nameOf(el);
+ return visible(el) && /^(Stop|Stop generating)$/i.test(name);
+ }),
+ };
+})()`;
+```
+
+Count **Start voice** inside the unique visible Work composer
+(`[data-codex-composer-root]`). Native Chat **New voice chat** stays out of Work
+acceptance. On overlay, `voiceStartCapability` is the overlay hook. Hook
+`active` is not proof that the composer button is visible.
+
+```js
+const inspectClient = await CdpClient.connect(primary.webSocketDebuggerUrl);
+try {
+ const status = await evaluate(inspectClient, SANITIZED_STATUS);
+} finally {
+ inspectClient.close();
+}
+```
+
+### 3. Authorized live refresh of the primary page
+
+Build an ephemeral browser IIFE from current repository modules with esbuild.
+Do not copy vendor chunks. Write the bundle outside tracked source. Evaluate it
+once on the primary page. Dispose existing handles first:
+`installExternalVoiceLaunchHook` and `installVoiceCapabilityProjection` return
+the already-installed object when present.
+
+Desktop CSP forbids dynamic code generation, so set Zod `jitless` before
+install. Refresh only the voice hooks. Leave the production Renderer binding,
+native capability atoms, global Statsig, `model_provider`, global `fetch`, and
+immutable RPC stubs unchanged.
+
+```js
+import { mkdtemp, readFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { build } from "esbuild";
+
+const root = process.cwd();
+const outfile = path.join(await mkdtemp(path.join(os.tmpdir(), "codexhost-voice-")), "primary.js");
+await build({
+ bundle: true,
+ platform: "browser",
+ format: "iife",
+ target: "es2024",
+ outfile,
+ stdin: {
+ resolveDir: root,
+ loader: "ts",
+ contents: `
+ import { z } from "zod";
+ import { installExternalVoiceLaunchHook } from "./packages/renderer-extension/src/renderer-external-voice-launch.ts";
+ import { installVoiceCapabilityProjection } from "./packages/renderer-extension/src/renderer-voice-capability.ts";
+ z.config({ jitless: true });
+ window.__codexhostVoiceCapabilityV1?.dispose();
+ window.__codexhostExternalVoiceLaunchV1?.dispose();
+ installExternalVoiceLaunchHook();
+ installVoiceCapabilityProjection();
+ `,
+ },
+});
+const refreshClient = await CdpClient.connect(primary.webSocketDebuggerUrl);
+try {
+ await evaluate(refreshClient, await readFile(outfile, "utf8"));
+ const deadline = Date.now() + 20_000;
+ let status;
+ while (Date.now() < deadline) {
+ status = await evaluate(refreshClient, SANITIZED_STATUS);
+ if (status?.externalLaunch === "active" && status?.voiceCapability === "active") break;
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ if (status?.externalLaunch !== "active" || status?.voiceCapability !== "active") {
+ throw new Error(
+ `Voice hooks did not become active within 20s (externalLaunch=${status?.externalLaunch}, voiceCapability=${status?.voiceCapability})`,
+ );
+ }
+} finally {
+ refreshClient.close();
+}
+```
+
+Installation is async. The loop is bounded hook initialization: it waits until
+`externalLaunch` and `voiceCapability` read `active`, then throws if they never
+do. It does not prove a committed React snapshot, owned subscription, or visible
+button; that verification is step 4. `Runtime.evaluate` of the IIFE returns
+`undefined`; the custom `evaluate` helper allows that. Use the current production
+profile from `CLIENT_OWNED_VOICE_ASSETS` for the observed `desktopVersion`. On
+exact **26.1021.31345** the audited exports
+are `capabilityInit=l0n`, `capabilityFamily=c0n`, `inferenceGateFamily=Cnr`,
+service `Qot`, bridge `bIt`, and fetch `Htr` from initial, as recorded in the
+qualification section above. Those names document that profile. They are not a
+generic discovery contract. Resolving an arbitrary cache key on a family does
+not identify the inference gate; an unrelated family can read `active` while
+projected `voice.realtime` / `voice.thread` remain false.
+
+### 4. Verify composer behavior
+
+Confirm the control, not only hook status. Use an idle, unique, visible **LOCAL**
+Work composer with gateway ready and **Work → Voice connection** set to
+Configured gateway. Pass: exactly one **Start new voice chat** button is visible
+and enabled; native capability stays false; the owned subscription reads true.
+
+An idle blank composer may be selected for this check without submitting text or
+editing existing drafts or turns. A running agent task stays running. A visible
+Stop control can hide the microphone while the turn is busy. Native Voice Settings retain their account access policy; restoring their controls
+does not establish call eligibility for every voice entry point.
+
+### 5. Overlay document
+
+The avatar overlay is a separate JavaScript realm with its own document-start
+registration. To replace a pending overlay hook, dispose only
+`__codexhostVoiceStartCapabilityV1`, then install from
+`renderer-voice-start-capability.ts`:
+
+```ts
+import { z } from "zod";
+import { installVoiceStartCapabilityHook } from "./packages/renderer-extension/src/renderer-voice-start-capability.ts";
+z.config({ jitless: true });
+window.__codexhostVoiceStartCapabilityV1?.dispose();
+installVoiceStartCapabilityHook();
+```
+
+That wrap applies to future `registerRealtimeStarter` calls. It cannot replace a
+callback already registered on this overlay document. Bind an already-registered
+starter with a fresh idle overlay document that received the updated
+document-start injection, or with the user's later manual full restart. Refreshing
+the primary page does not fix overlay registration. Leave the overlay document
+alone during an active or unknown call. A full Desktop restart is not required
+to validate primary composer visibility.
+
+### 6. Optional overlay validation without a call
+
+A bounded overlay check can confirm gateway readiness and a committed projected
+starter snapshot without opening the microphone. Import
+`initializeVoiceCapabilityFamilies` and `bindVoiceCapabilitySubscriptions` from
+`renderer-voice-capability.ts`, `overlayVoiceStarterFibers` from
+`renderer-voice-start-capability.ts`, and `requestVoiceHost` from
+`renderer-client-owned-voice.ts`. Use the current profile's exact `bridge` export
+and `realtimeOnly: true`. Always `binding.dispose()` in `finally`.
+
+```js
+const ready = await requestVoiceHost(
+ shared[profile.bridge].getInstance(),
+ {},
+ "codexhost/voice/gateway-status",
+);
+const { family, gates } = initializeVoiceCapabilityFamilies(initial, profile);
+const binding = bindVoiceCapabilitySubscriptions(
+ () => overlayVoiceStarterFibers(document, overlay.AvatarOverlayNativePage),
+ family,
+ gates,
+ () => true,
+ true,
+);
+try {
+ for (let i = 0; i < 40 && !binding.current(); i += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ return {
+ gatewayReady: ready === true,
+ overlayProjectionCommitted: binding.current() === true,
+ };
+} finally {
+ binding.dispose();
+}
+```
+
+Report those two booleans. Observed pass on 26.1021 was `gatewayReady: true` and
+`overlayProjectionCommitted: true` with no call. This check does not replace an
+already registered overlay starter.
+
+### 7. Persist built bundles
+
+Live evaluation lasts for the current document. For later documents, build and
+stage the installed files:
+
+```sh
+npm run build:renderer
+```
+
+Copy `packages/renderer-extension/dist/production.js` onto the running
+installation's `app/renderer-extension.js`, and
+`packages/renderer-extension/dist/cpa-live-voice-hook.js` onto `app/cpa-live-voice-hook.js`.
+On the macOS app bundle those paths are
+`/Applications/codexhost.app/Contents/Resources/app/`. Discover the equivalent
+payload from the current Controller `--renderer` argument on other layouts.
+Back up both files and hash-verify the copies. Skip `npm run install:local` when
+the goal is only to stage assets; that command can restart Desktop.
+
+The running Controller can keep the previous source in memory. A disk copy does
+not replace the active script or change document-start registration for future
+pages in this process. A later package update can replace a locally staged fix.
+Leave `/Applications/ChatGPT.app` and `app.asar` unchanged.
+
+### Limits
+
+- Live injection is the current document only.
+- Overlay starter wrapping is future registrations on that overlay document.
+- Physical microphone, gateway call creation, audible reply, and hangup remain
+ separately unverified on 26.1021.
+- Keep call IDs, SDP, credentials, and runtime tokens out of docs and output.
+
+## Desktop 26.1016.62342 qualification
+
+Work/gateway and native Chat have independently audited **26.1016.62342** build
+**21477** profiles. The exact **61520** profiles remain supported, so installing
+the host before upgrading Desktop preserves the working baseline. Unknown
+versions, version/asset mismatches, incomplete pairs and mixed audited assets
+fail closed before native imports. Without version metadata, exactly one
+complete audited initial/shared pair is required. The selected profile also
+owns its overlay or native Chat START/prewarm imports.
+
+| Asset | 61520 baseline | 62342 target |
+| --- | --- | --- |
+| Initial | app-initial-c95ddb62696e.js | app-initial-dddef47d6ef6.js |
+| Shared | app-shared-2eda13eb7044.js | app-shared-fc3316d48427.js |
+| Primary Chat owner | app-primary-58a09006cc0c.js | app-primary-10e84ec314b8.js |
+| Work overlay | avatar-overlay-native-page-fcc17283519a.js | avatar-overlay-native-page-ef8332c6a2fb.js |
+| Native Chat START | start-voice-18b08f0acdd7.js | start-voice-7cbf0a39d53b.js |
+| Native prewarm entry | voice-prewarm-94271e581c1b.js | voice-prewarm-5d107129ac1c.js |
+| Native prewarm controller | voice-prewarm-de985dbe0490.js | voice-prewarm-9695e173952c.js |
+| Voice session | voice-session-7d5ee22a0da4.js | voice-session-c6b0f08e69a2.js |
+
+The 62342 ASAR SHA-256 is
+`5b5cf52887cfd4de19cb5862155a8ef6cf58830c0a2fc592b72060cfcc3be869`.
+Acquisition reads selected entries through ZIP ranges, then individual ASAR
+header offsets; it does not install Desktop or extract the full ASAR.
+
+After replacing imported asset hash suffixes, the primary owner, overlay, START,
+prewarm and voice-session sources equal 61520 byte for byte. Initial/shared
+also differ in the embedded build commit:
+`d7acb7a1b13f5e0f32a1e399e3fce368b08bd754` becomes
+`293b23553a2ef1949121cc87c52983472a85ea23`.
+The relevant CSS is byte-identical. Thus the 61520 contract table below also
+applies to the corresponding 62342 files at the same formatted line numbers
+(Prettier 3.9.6, Babel defaults, printWidth 80).
+
+Work evidence includes initial:111047/111148 (capability and inference gate),
+:682702/682778 (existingCall and WebFetch bootstrap), overlay:4084 (owned realtime
+subscription) and :4693 (starter registration). Native Chat evidence includes
+START:1119/1173/1194/1260; primary:121258/121370/125082/125124 (RouteScope and
+inner ComposerScope/form); shared:84170/84202/84461/104176 (provider chain,
+node token, scope ref and AppScope); initial:256888/685663/685754 (store,
+runtime flag and VoiceRoom). Export names and the guarded transcript adoption
+contract remain unchanged.
+
+A broader offset comparison covers all 15,305 Renderer JS/CSS files. Aside from
+asset references/build metadata, changes are CSS module identifiers in control
+and placement UI, and the automation dialog's model-list client version changing
+from 0.163.0-alpha.11.1 to .11.2. The consumed composer, routing, usage-gate and
+voice contracts have no source change requiring additional integration.
+
+This is source qualification plus isolated regression coverage. The installed
+upstream app remains 61520 until an explicit upgrade. No 62342 live Desktop,
+physical microphone, audible reply, transcript adoption or cleanup acceptance is
+claimed. Issues [#7](https://github.com/karlorz/codex-host/issues/7) and
+[#8](https://github.com/karlorz/codex-host/issues/8) retain functional acceptance;
+[#4](https://github.com/karlorz/codex-host/issues/4) retains the original Chat
+acceptance limits. A matching compat-daily pin warrants a recovery comment,
+independently of those live checks.
+
+## Desktop 26.1016.61520: 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-c95ddb62696e.js` and
+`app-shared-2eda13eb7044.js` assets (Desktop **26.1016.61520**), 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
+when the matching route uses the gateway. Codex Work honors **Work → Voice connection**; external Harness calls always use Host. **Chat → Codex → Configured gateway** uses the same Host path. **Chat → Codex → Desktop default** leaves `/wham` untouched. **Chat → ChatGPT** invokes native browser voice separately and leaves `/wham` untouched. Other WebFetch requests
+pass through with their original receiver and
+arguments.
+External Harness ownership also comes from an explicit `codexhost/` backend
+model in native request metadata, so a cold avatar overlay does not depend on
+the main composer's routing state or remembered launch. Explicit Chat ownership
+still takes precedence. Safe bootstrap diagnostics report the selected
+`desktop` or `gateway` route without model names, identifiers or credentials.
+No global fetch, message transport or application archive is replaced.
+
+Explicit Work launch ownership selects the Work connection preference in the
+avatar overlay, where no primary Composer is present. It applies even when the
+backend model name has no `codex` marker (for example, `gpt-6.1-sol`) or call
+metadata omits the model. Model-name and visible-Composer detection remain
+fallbacks for launches without an explicit product experience. Explicit Chat
+ownership still selects the Chat connection preference.
+
+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 for the request's mode 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.
+
+### Work voice capability on 26.1016.61520
+
+The audited native capability family requires first-party inference for `voice.realtime` and `voice.thread` when rollout `3683713372` is enabled. A custom provider can therefore hide Start voice even when gateway transport is ready.
+
+`renderer-voice-capability.ts` projects the owning visible local Work composer's subscriptions to those two capabilities. It requires the exact 26.1016 initial/shared assets, active client-owned and external-launch hooks, enabled injection, a successful live Host gateway-readiness check, and the Work gateway choice or an explicitly owned local external Harness selection. ChatGPT voice, remote Hosts and other capabilities retain native eligibility. The explicit Codex Chat gateway option qualifies the owned Chat form and overlay as described below. Hidden composer roots do not make the visible composer ambiguous; multiple visible roots fail closed.
+
+Runtime settings reports return `gatewayReady: null` because the console skips its callback to the requesting Host. The Renderer reads the existing local `codexhost/voice/gateway-status` method directly when readiness is omitted. Transient settings or status request failures retain the last successful readiness; an explicit negative result or disposal revokes it. No successful readiness read means the projection stays pending. Route and
+readiness caches share the document lifetime with the retained voice-hook globals.
+A replacement Renderer bundle updates the same caches; disposing the settings
+poll revokes readiness until its replacement completes a successful check.
+
+The projection replays the native readonly capability dependencies with the first-party inference restriction omitted only for that evaluation. It retains loading/error, access-policy, rollout and config-feature checks. It does not change the provider name, native capability atoms or global Statsig results. Family cache-key, scope-chain, signal and committed subscription ownership must all agree; absent, ambiguous or changed bindings fail closed. Changing scope, routes, readiness or injection restores native subscription getters.
+
+A controlled live Renderer trial on 26.1016 showed Start voice changing from absent to one visible enabled button in an existing external Harness Work composer. Native capability values and the inference rollout remained unchanged. Disposing the projection restored native visibility. No call, microphone capture, audio connection or remote cleanup was tested.
+
+### Work gateway call-start capability on 26.1016.61520
+
+The composer and the persistent avatar overlay have separate capability readers.
+The overlay's native starter reads `voice.realtime` again and emits
+`Voice chat is not ready (voice_disabled)` when that reader is false. This check
+precedes microphone setup, workspace readiness, call creation and app-server
+attachment. Showing the composer button alone does not qualify call startup.
+
+`renderer-voice-start-capability.ts` wraps the overlay's native
+`registerRealtimeStarter` callback through the service reference. It accepts
+explicit local Work composer starts using the Work gateway preference, explicit Codex Chat gateway starts, or a
+new-thread request carrying an explicit `codexhost/` model. It requires active
+gateway transport, a successful local Host readiness read, and the exact initial,
+shared and `avatar-overlay-native-page-fcc17283519a.js` assets. Within the exported
+native overlay page, exactly one owned `voice.realtime` subscription can qualify.
+The hook waits for React's committed capability snapshot before invoking the
+native starter. Cancellation, failed startup, completed startup, unregistration
+and disposal release the projection. Native access policy, treatment, workspace,
+media and busy checks remain authoritative. ChatGPT Chat, remote Hosts, global
+hotkeys and unowned starts retain native eligibility.
+
+The overlay hook-only bundle must be updated alongside the primary Renderer.
+An already-running Controller can retain its previously loaded bundle; staging
+the installed file does not change that in-memory source. A controlled reload of
+an idle voice overlay can qualify the new document without restarting Desktop or
+reloading the primary window. The Controller's Zod `jitless` bootstrap must run
+before evaluating the bundle under Desktop's Content Security Policy.
+
+A controlled native Work/Harness trial on 26.1016 returned gateway HTTP 201,
+applied the SDP answer, attached app-server with `transport: existingCall`, and
+reported connected while `model_provider = "custom"`. Silent generated audio
+replaced physical microphone input for the trial. Native stop restored an idle
+overlay, native capability false and the unchanged inference rollout. Physical
+microphone capture and spoken conversation quality remain separately untested.
+
+cc-switch can deliberately use `model_provider = "custom"` to unify official and
+third-party resume history. Its local provider writer preserves ChatGPT login
+separately and can publish provider-scoped gateway credentials. CodexHost reads
+that configuration without renaming the provider or modifying history. The
+`voice_disabled` rejection is Desktop's capability restriction; it is not an
+upstream gateway response or a session-history parsing error.
+
+### Existing and new ChatGPT conversations: native Instant Connect
+
+The retained native Chat baseline is **26.1016.61520** build **21421**:
+`app-initial-c95ddb62696e.js`, `app-shared-2eda13eb7044.js`,
+`start-voice-18b08f0acdd7.js`, and `voice-prewarm-94271e581c1b.js`.
+Native Chat uses Desktop's browser Instant Connect stack. Its authentication,
+account availability, START/RESTART commands, WM requests, microphone, VoiceRoom
+and cleanup remain native. Codex Chat uses the separate Codex overlay and its
+own connection preference. Missing or ambiguous native Chat bindings reject
+without falling through to that overlay.
+
+The audited 61520 baseline ASAR SHA-256 is
+`c9945f821fcbaf48424933a82860beac4dd4ad8b8a5444a75568fa6e38a1ad4b`.
+Only individual assets were read by ASAR offsets. These native file references
+use Prettier 3.9.6 Babel formatting with default options (printWidth 80):
+
+| Contract | 61520 evidence and binding |
+| --- | --- |
+| Native START | `start-voice-18b08f0acdd7.js:1119` accepts scope, composer props, metadata and signal; `:1173` reads `B1n`; `:1194` dispatches START. Exports `n`/`t` at `:1260`. Native navigation, active-session and availability checks remain authoritative. |
+| Scope ownership | `app-primary-58a09006cc0c.js:121258` holds the composer's RouteScope ref (`initial.MOr`). `app-shared-2eda13eb7044.js:84461` defines that ref's get/set and chain. Its AppScope node must match the native context (`shared.Qst`, defined at `:104176`). |
+| Saved and draft selection | `app-primary-58a09006cc0c.js:121370` builds an inner chatgpt ComposerScope value with the current selection; `:125082` mounts its provider above the form at `:125124`. `app-shared-2eda13eb7044.js:84170` publishes the chain through that context provider; `:84202` stores the scope descriptor as the node token. Draft IDs originate in useState at `:129002`. The nearest committed ComposerScope provider between the form and owner must carry the native `initial.CDr` token, the owner's RouteScope and AppScope nodes, and the same selected conversation ID. The owner's consumed context can still be the outer new-Chat scope and cannot substitute for the form's provider. |
+| Store and runtime | `app-initial-c95ddb62696e.js:256888` exports the native store accessor as SPn. `:685663` initializes browser voice with oT and flag aT; `:685754` mounts native VoiceRoom. `voice-session-7d5ee22a0da4.js:7745` publishes VoiceRoom, START command handling and native cleanup through initializeVoiceSession. |
+| Prewarm | `voice-prewarm-94271e581c1b.js:1` re-exports the controller from `voice-prewarm-de985dbe0490.js`. Its AppScope, ComposerScope, selected-system-hint reads, eligibility, abort watchers and cache ownership stay native. React and createRoot are shared.FBt and shared.PNt, the same React/ReactDOM instance used by Desktop. |
+| Adopted-draft navigation | `initial.Ycr` is the current location; Nir invokes native AppNavigation. B1n resolves the adopted backend ID; n0n and Y1n resolve the transcript mapping/current node. `shared.Vot` constructs the draft page path. Navigation waits for the connected session, adopted ID and current transcript message; an early server ID alone is insufficient. |
+| Primary action presentation | `app-initial-c95ddb62696e.js:202194` places `icon-primary-action text-composer-primary` on the icon, while `:202249` applies disabled opacity/cursor classes to the button. Voice reuses Send geometry and colors, with its own disabled state. |
+| Home headline | `app-primary-58a09006cc0c.js:140384` marks the rotating headline with data-headline; `:140770` puts it in the --vt-splash-screen-headline container. During active native voice on that same Chat draft, the unique visible headline container is hidden with visibility to preserve layout and leave room for the native orb. Ending, leaving the eligible composer or disposal restores its prior visibility. Work voice does not suppress the headline. |
+
+The visible Chat owner must be non-temporary, writable, and allow external
+egress. Its props, selection context, RouteScope chain, AppScope context and
+inner ComposerScope must agree. Saved Chats require a backend ChatGPT UUID;
+unsaved Chats require a valid native local-chatgpt draft locator and no backend
+ID. A Desktop Thread-Id is never converted into a ChatGPT UUID.
+
+For ChatGPT voice, the hook mounts one native prewarm controller per AppScope
+under both native contexts and enables the native browser-voice flag. RouteScope
+replacement during navigation updates the provider chain without replacing the
+AppScope runtime. Starting or active voice retains the runtime while the composer
+is hidden. Leaving Chat after voice ends restores the prior flag and unmounts
+the controller. Native eligibility determines whether a selection can prewarm.
+
+The Start Voice waveform sits immediately before the visible native Send button,
+including compact Chat home composers without Dictate. It follows Send when its
+wrapper changes and is removed while the form has no unique visible submit
+action (such as during Stop).
+It matches the native voice control row's gap-2 spacing (normally 8px),
+supplementing only the spacing missing from its parent and centering the action.
+Native voice's microphone/end row uses flex items-center gap-2 in
+voice-session-7d5ee22a0da4.js:7563. Existing parent spacing is not doubled.
+The waveform uses the native contrasting icon foreground and stays enabled
+when an empty message disables Send. Its dimming follows voice startup,
+streaming and active-call state independently of Send.
+It invokes native START with the current
+RouteScope, props, composer metadata and cancellation signal. While present it
+hides the competing Codex Start voice entry in that Chat composer. Switching to
+Codex voice or leaving Chat restores the original entry. Saved Chats remain
+selected. Draft navigation is attempted once, after adoption, and cancels if the
+user leaves, the session changes or ends, or the binding is released. Missing
+navigation exports leave native voice available with manual transcript selection.
+
+Home stays selected until native voice has connected, adopted the Chat and
+published the current transcript message. Starting while muted or without
+microphone input does not by itself satisfy those conditions. The orb and
+microphone/end controls alone do not establish transcript readiness. Early page
+navigation can interrupt native voice; no timer or early server ID bypasses
+the adoption and transcript guards.
+
+Instant Connect may adopt a prewarmed voice-session ID during startup. The
+native `initial.HJt` request-generation counter remains unchanged for that
+adoption and advances for a new voice intent. Draft selection tolerates the
+startup ID change only with the same proven generation; it still cancels on
+a new intent, an established session ID change, navigation, abort or voice end.
+If the optional generation reader is absent or invalid, session ID changes
+continue to cancel selection. Native START remains available independently.
+
+The native version/asset and scope guards fail closed on unsupported builds.
+Lifecycle tests cover saved and draft starts, prewarm contexts, navigation,
+cancellation, retry, hidden composers and disposal. Native Chat microphone,
+connected WM audio and account eligibility have **not** been exercised on the
+running Desktop: Computer Use refuses access to com.openai.codex. Static source
+qualification and tests do not establish those live results. Issue
+[#4](https://github.com/karlorz/codex-host/issues/4) retains that acceptance limit.
+A matching compat-daily pin clears version drift only.
+
+### Codex Chat gateway capability
+
+Chat → Codex → Configured gateway qualifies the visible Chat composer's owned
+voice.realtime/voice.thread subscriptions, including the native hooks on the
+form's owning component. The owner must match the form's eligible Chat props and
+selection context; ambiguous owners or multiple visible forms fail closed. Work
+routing state is not required to claim a Chat form. The overlay starter accepts
+explicit local Chat starts only when that Codex gateway preference is active.
+ChatGPT voice and Codex/Desktop default retain native capability checks. Both
+composer and overlay projections preserve non-inference availability checks;
+no global Statsig or provider-name override is used.
+
+The client-owned WebFetch hook initializes shared.Oit before reading its lazy
+singleton exports. Call creation for explicit Codex Chat gateway launches uses
+Host credentials; native ChatGPT voice does not use this /wham transport.
+
+Debug events use `__codexhostChatVoiceDebug` and the bounded localStorage key
+`codexhost.chat-voice-debug.v1`. They contain event names, timestamps and routing
+booleans, with no conversation IDs, SDP, credentials or raw errors. Native store
+subscriptions additionally record active/connected/session-started and
+server-conversation-ready flags, an allowlisted connection state, and the
+allowlisted microphone request state. A
+START return is a command acknowledgment, not evidence of a connected call.
+These subscriptions are removed with the AppScope binding.
+
+The production Renderer entry installs this hook on every document. Its polling
+rebinds after leaving Chat, returning, or native composer replacement; no manual
+session activation is required. The installed codexhost Renderer must contain the
+change for it to survive a Desktop restart. A live CDP injection alone does not
+update the installed release, and a subsequent codexhost update replaces a local
+installation patch.
+
+Attended verification uses both an existing web-backed Chat and a new Chat draft:
+click **Start Voice**,
+confirm the conversation remains selected, the native state reports `wm` and
+`connected`, and **End Voice** is present. Confirm speech and microphone release
+separately. Leave and return to each Chat, then reload Desktop and confirm the
+waveform control returns. Then verify Work/Grok voice still reaches the configured
+CPA gateway.
+
+### 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.
+
+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.
+
+The legacy gate override reads the Work gateway preference at install time. Per-request routing and the separate Chat gateway switch apply to the 26.1013 client-owned fetch wrap.
+
+## How it is installed
+
+1. When the saved injection setting is on (unless overridden by `CODEXHOST_CPA_LIVE_VOICE=0|1`), 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 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.
+
+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 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 for Desktop **26.1007.30912** (gate path, primary bundle `app-initial-6350d04672d0.js`), **26.1013.40934** (client-owned path), **26.1016.51333**, and **26.1016.61520** (client-owned WebFetch wrap on `app-initial-c95ddb62696e.js` / `app-shared-2eda13eb7044.js`). Native Chat voice is pinned separately to 26.1016.61520 with live microphone/audio acceptance pending. 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
+
+- 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.
+
+## 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` (26.1007, export `fN`) or
+`app-shared-2eda13eb7044.js` (26.1016.61520, export `ist`) 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
+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}`. 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 handoff text and user `turn.done` transcripts go to that same external Thread.
+An open call also follows ordinary text or attachment tasks in that Thread,
+including a task already running when the call opens. Completed agent progress
+messages are forwarded during work: explicit `commentary` is eligible at Item
+completion; an unphased message is eligible after a later Item starts, proving
+that it is not the inferred final answer. The final result waits for successful
+Turn completion. Partial messages, private reasoning and tool 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. 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.
+
+Context forwarding confirms delivery to the voice control connection. Exact
+audible progress and completion still require an attended Desktop call test.
+
+External Agent Messages completed during a voice call use Desktop's native
+`::codex-realtime-inline{}` Markdown presentation. The Host adds the directive
+only to Desktop projections; Harness history and speech context retain the
+original answer. The Turn mapping records `realtimeInline: true`, so history
+reloads and Host restarts preserve the same written presentation. This keeps
+exact links and other Markdown visible alongside the speech transcript.
+
+Grok text Item IDs use the native Session, visible Turn index, Item kind and
+message ordinal in both live projection and history replay. Chunk boundaries,
+optional native message IDs and message text do not determine those IDs.
+History hydration can therefore replace the streamed Item while preserving
+separate messages that happen to contain identical text.
+
+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,
+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.
+
+### Automated smoke and attended verification
+
+From the repository root, run:
+
+```sh
+npm run test:voice:smoke
+```
+
+This builds the TypeScript packages and packaged Harness plugins, then runs the
+focused voice regression files using `tests/vitest.config.js`. It does not launch,
+reload or stop Desktop, create a real call, or require gateway credentials. It
+covers:
+
+- Completed public progress, streamed final text, successful Turn completion,
+ and exclusion of private reasoning and tool output.
+- Ordinary text/attachment tasks during an open call, tasks already running
+ when voice opens, and output held until the control connection is ready.
+- Linked handoff deduplication, history seeding, gateway routing and cleanup,
+ and suppression of late speech after hangup without cancelling Harness work.
+- Claude native completed scout results containing quoted background-launch
+ wording, while preserving genuine asynchronous scout launches.
+- Overlay delivery after hangup, new-chat Harness selection, audited call hooks,
+ preference gating, and CSP-compatible validation setup.
+
+These files also run in the normal TypeScript CI suite; the named command is a
+local focused entry point. Use it instead of `npm test -- `: the root
+`test` script invokes the broader TypeScript and Rust suites.
+
+For attended verification, use the existing configured Desktop installation;
+no separate voice hook, mock process or worktree is required:
+
+1. Open an external Harness Thread and start voice. Keep the call open through
+ task completion.
+2. Submit a normal text task while the call is open, asking for a short read-only
+ inspection, one separate public progress message before tool work, and a brief
+ final summary. This exercises ordinary-turn forwarding. A task spoken into
+ voice exercises the delegation path instead.
+3. Verify progress only if the Harness actually emits a separate public message.
+ A progress heading inside the final answer is one final message and does not
+ verify intermediate speech. Private reasoning and command output are excluded.
+4. Check that the final answer is heard before hangup, its written Markdown and
+ links appear in the voice timeline, the backend Turn completes, and Desktop
+ returns to idle. Reopen the chat and check the same links again. Compare a
+ manual Grok chat with an automatically delegated chat using the same Model
+ and Thinking option; each answer should appear once.
+5. If the UI appears stuck, preserve the call and inspect the backend Turn and
+ Desktop state before restarting or reloading. An active command is expected
+ to remain busy. Judge a stale busy state after the final backend reply.
+
+The attended Claude Code check on Desktop **26.1013.40934** on 19 October 2026
+confirmed an audible final answer, completed Turn and idle Desktop. That run
+emitted no separate public progress message, so intermediate audio remains
+unverified. Automated transport assertions do not establish physical playback,
+universal Model compatibility or recovery from every long-task failure.
+
+## Harness session rules (Grok ACP `_meta.rules`)
+
+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 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/*`.
diff --git a/docs/operations/fork-versioning.md b/docs/operations/fork-versioning.md
new file mode 100644
index 000000000..54908ec07
--- /dev/null
+++ b/docs/operations/fork-versioning.md
@@ -0,0 +1,267 @@
+# 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/*`.
+
+## Preserve Live Voice when syncing upstream
+
+This is the operational runbook for upstream syncs. Preserve fork patches, merge
+upstream on a branch from fork `main`, then verify the fork behavior and Desktop
+pins separately. The fork's [Live Voice integration](cpa-live-voice-desktop-hook.md)
+and [Grok Computer Use / IAB settings](../harnesses/grok/computer-use-settings.md)
+remain fork responsibilities.
+
+An upstream merge cannot by itself qualify Desktop voice. At the 22 October 2026
+baseline (`origin/main` at `10ba06b7`), Work and Chat pins are **26.1013.40934**;
+the reported Sparkle latest is **26.1016.61520** (fork issues
+[#3](https://github.com/karlorz/codex-host/issues/3) and
+[#4](https://github.com/karlorz/codex-host/issues/4)). The dirty Work overlay and
+capability projection target **26.1016.61520** and are not part of that baseline.
+Neither merging upstream nor changing a version string qualifies 26.1016.61520.
+Recheck the live appcast and exact source pins at each sync.
+
+### Protect the dirty tree and authorize the merge
+
+Documentation updates can proceed independently. Before an actual merge, settle
+both attended decisions: how to preserve dirty work, and whether to merge now.
+The recommended preservation is a named backup branch with a WIP commit after
+reviewing the file inventory; do not put an unreviewed bulk feature commit on
+fork `main`. Merely creating a branch does not save dirty files.
+
+1. Record `git status --short --branch`, the fork-main OID, staged changes and
+ untracked files. Inventory the voice overlay/projection and IAB work as well
+ as other local changes. Keep secrets, ignored build outputs and local
+ environment files out of commits.
+2. On the approved backup branch, explicitly stage the intended tracked and
+ untracked source files and create the WIP snapshot. Record its branch and
+ commit OID. Alternatively, park tracked and untracked work in an explicitly
+ named `git stash push --include-untracked` entry, record its OID, and inspect
+ its inventory. Verify that the recovery copy contains the intended files.
+3. Return to fork `main` with a clean tracked/untracked source tree before
+ creating the sync branch. Approved feature work may instead be reviewed and
+ integrated into fork `main` first. Do not discard remaining dirty changes to
+ make this precondition pass.
+4. Obtain Karl's explicit **merge now** decision. A recommendation to document
+ the process, silence, or a saved backup does not authorize the merge.
+
+On a disk-constrained checkout, use Git snapshots and targeted asset reads. Do
+not run Docker or extract the full Desktop ASAR. Run Rust validation only when
+an approved merge affects Rust and the selected checks require it.
+
+### Merge on a fork sync branch
+
+Before an upstream sync:
+
+1. Verify `origin` is `karlorz/codex-host` and `upstream` is
+ `BytePioneer-AI/codex-host`, including push URLs. Fetch both. Compare fork
+ `main` with `origin/main`; reconcile unexpected divergence before continuing.
+ Record the fork and upstream OIDs used for this sync.
+2. Create a uniquely named `sync/upstream-YYYY-MM-DD` branch from fork `main`,
+ then merge the recorded `upstream/main` OID there. Never reset or replace fork
+ `main` with upstream. A conflict-free merge still needs behavioral verification.
+3. Resolve conflicts and inspect overlapping changes by responsibility. Do not
+ apply a blanket choice of either side. Preserve the contracts below, or use
+ an upstream implementation only after equivalent behavior is verified.
+4. Restore the intended fork voice, overlay/projection and IAB changes from the
+ backup, reviewed commits or stash as needed. For a stash, apply the saved OID
+ and keep the recovery entry until verification finishes. Inspect each diff;
+ do not blindly replay a mixed WIP snapshot or restore whole upstream files.
+ Preserve fork package/update identity, `tools/compat-daily/`, the
+ `compat:daily` / `compat:daily:test` scripts and
+ `.github/workflows/compat-daily.yml`. IAB preferences must retain their real
+ supported/unsupported binding semantics.
+
+If resolution cannot preserve a contract, stop and record the unresolved
+ownership or behavior. An in-progress merge can be aborted with `git merge --abort`
+before applying saved WIP. Keep the backup/stash; do not use a destructive reset
+on fork `main` as recovery.
+
+### Required fork contracts
+
+Required voice contracts:
+
+- **Existing switches:** Console **Inject live-voice hook** (default on, relaunch)
+ is the process-lifetime wrap. Desktop **Work voice** defaults to **Configured gateway**
+ and **Chat voice** defaults to **ChatGPT**. Chat Instant Connect on
+ `https://chatgpt.com/realtime/wm` keeps
+ Desktop ChatGPT login. Work uses Host → gateway. A disabled Work switch calls
+ the original `/wham` WebFetch. The usage-warning switch does not route calls.
+- **Native call creation:** On the audited client-owned Desktop flow,
+ `renderer-client-owned-voice.ts` forwards the native call-create request through
+ `codexhost/realtime/call` to `native-voice-gateway.ts`. Preserve the Host RPC
+ dispatch, Renderer entry points, shared `realtime-call.ts` contracts and
+ `realtime-gateway-config.ts`; gateway credentials stay in the Host, request
+ headers remain allowlisted, and gateway failures do not fall back to Desktop
+ OAuth.
+- **CSP:** `production-controller.ts` sets Zod `jitless` before both the primary
+ Renderer and overlay bundles are evaluated. Preserve strict CSP without adding
+ `unsafe-eval`.
+- **Lifecycle and Harness:** Preserve cancellation/hangup cleanup, external
+ existing-call attachment without duplicate creation, and the selected Harness
+ for new-chat launches. Official Codex realtime handling continues through its
+ native app-server path.
+- **Version boundary:** Preserve exact audited asset checks. After a Desktop
+ update, re-audit its native exports and call ownership before enabling another
+ build; a passing source merge does not qualify new Desktop assets.
+- **Rollout and scope:** Keep `model_provider = "custom"`. Do not globally
+ override Statsig `3683713372`. Work capability/start projection must remain
+ scoped to its audited owner and assets. Chat voice qualification for 26.1016
+ stays deferred; preserve the existing qualified Chat path and fail closed on
+ unqualified builds.
+- **Gateway boundary:** Disabled-account selection and any future automatic
+ quota retry belong to CPA. This fork's voice override does not implement
+ account failover. Do not edit CPA core or deploy a gateway as part of a routine
+ codex-host sync.
+
+### Re-audit pins and verify the sync
+
+Run `npm run compat:daily` from the candidate tree and record the report. This
+fetches only the public Sparkle appcast, reads the local pins and exits nonzero
+on drift; it neither updates pins nor qualifies Desktop behavior. Follow
+[the compat-daily audit procedure](../../tools/compat-daily/README.md) to inspect
+the exact assets/exports and change pins only with matching qualification
+evidence. Keep Work and Chat qualification separate. Deferred Chat may continue
+to report drift; record that limitation rather than changing its pin to make
+the check green.
+
+After resolving the sync, select checks for the actual merged diff using the
+repository pre-push guidance. For voice changes, build the TypeScript packages
+before dependent tests:
+
+```bash
+npm run build:typescript
+npm run typecheck
+npm run lint
+npm run build:renderer
+npx vitest run --config tests/vitest.config.js \
+ packages/desktop-control/test/production-controller.test.ts \
+ packages/shared-contracts/test/realtime-call.test.ts \
+ packages/renderer-extension/test/renderer-client-owned-voice.test.ts \
+ packages/renderer-extension/test/renderer-external-voice-launch.test.ts \
+ packages/renderer-extension/test/renderer-cpa-live-voice-hook.test.ts \
+ packages/renderer-extension/test/renderer-cpa-live-voice-preference.test.ts \
+ packages/host-runtime/test/native-voice-gateway.test.ts \
+ packages/host-runtime/test/external-voice-gateway.test.ts \
+ packages/host-runtime/test/external-voice-lifecycle.test.ts \
+ packages/host-runtime/test/app-server-host.external-voice.test.ts
+npm run test:e2e -- renderer-voice-csp.spec.ts
+npm run compat:daily:test
+npm run compat:daily
+git diff --check
+```
+
+The browser regression requires an installed Playwright browser. If using an
+installed Chrome executable, set `CODEXHOST_PLAYWRIGHT_EXECUTABLE_PATH` for that
+test command. Broaden checks for any additional packages or Rust code affected by
+the sync; the voice set is not a substitute for those checks.
+
+With restart and live testing authorized, use `npm start` to rebuild and launch
+the sync result. Confirm the running overlay hook is active on audited assets,
+call creation reaches the configured gateway, WebRTC connects, and hangup is
+confirmed without CSP errors. Then verify native-button startup, audible output
+and the selected Harness. A silent transport probe alone does not establish
+microphone/conversation acceptance. Record the tested commit, Desktop version,
+checks and remaining limitations in the wiki without credentials, SDP or call
+identities.
+
+### Review and publish to the fork
+
+Record the backup/stash OID, fork/upstream OIDs, candidate commit, Desktop
+version/assets, checks and any deferred qualification in the wiki. Review the
+sync diff and limitations before integrating it into fork `main`; keep the
+recovery snapshot until the intended fork work is accounted for.
+
+Prepare a local commit for review. Push only with explicit authorization, naming
+the remote and branch, for example `git push origin sync/upstream-YYYY-MM-DD`.
+Use `-R karlorz/codex-host` for any authorized PR. Never push to `upstream` or
+force-push `main`. Integrating into fork `main`, releases and production changes
+are separate authorized actions. This runbook does not install an automatic
+sync guard or change branch protection.
+
+## Tag rule
+
+Fork versions stay on the **upstream version that is currently held**, plus a numeric fork counter.
+
+| 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`, `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-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.
+
+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. 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 |
+| --- | --- | --- |
+| 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:** `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-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.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
+
+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
+```
+
+`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 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.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.3:
+git ls-remote --tags origin 'refs/tags/v0.13.3-*'
+```
+
+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.
diff --git a/docs/platforms/linux/linux.md b/docs/platforms/linux/linux.md
index 47f8edbe5..788432ab9 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 be9b0e37d..80fd73151 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/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/docs/project/README.ko.md b/docs/project/README.ko.md
index 7fe4fc7f0..8574e21d6 100644
--- a/docs/project/README.ko.md
+++ b/docs/project/README.ko.md
@@ -30,7 +30,7 @@
-