☰
Hex 2.0.17 深度解析:OpenCode 安装发现、工作区编码与子进程边界
2026/11/3 2:31:39 网站建设 项目流程
  • 语音
  • 桌面应用
  • 本地部署
  • 人工智能
  • 音频

【免费下载链接】hex

Local voice dictation for mac, windows and linux

项目地址:https://gitcode.com/GitHub_Trending/hex19/hex
点击查看免费下载

Hex 是一款本地优先的语音听写工具,语音转写在本机完成,而 Voice Action 与 Modes 的文本润色则交给独立的 OpenCode V2 CLI 处理。2.0.17 这个补丁版本没有引入新界面,而是集中修复了 Hex 与 OpenCode 之间那条最脆弱的链路:能不能找到 CLI、能不能把请求发到正确的目录、以及在出错时能不能干净地收场。本文以发布说明 docs/releases/2.0.17.md 为骨架,结合 src/dictation_processor.rs 的实际实现与测试,把这三点背后的机制讲透。

2.0.17 到底改了什么

发布说明只有三条,但每一条都对应一处具体的工程约束:

发布说明中的变更具体含义对应实现位置
将有界 OpenCode 发现扩展到 pnpm 11、NVM、fnm、nodenv 安装,且不启动 shell、不扫描整个文件系统在固定候选目录列表中补充 Node 生态的全局 bin 目录与版本管理器目录,全部通过直接文件系统调用完成src/dictation_processor.rs
正确编码隔离的 OpenCode 工作区,并在安装位置移动或 OpenCode 启动瞬时失败时恢复工作区路径经规范化与百分号编码后写入x-opencode-directory请求头;发现结论按可执行文件指纹缓存,文件变化即失效;对 "Model unavailable" 错误做一次限时重试src/dictation_processor.rs、src/dictation_processor.rs
限制 OpenCode 子进程输出,并在取消或超时时终止完整进程组,避免后代进程拖住听写处理输出上限 16 MiB;子进程放入独立进程组,结束时对整个组发SIGKILL,再 join 读取线程src/dictation_processor.rs

理解这三条的前提是弄清 Hex 与 OpenCode 的关系:docs/opencode-compatibility.md 明确写道,Hex 不捆绑、不锁定 CLI 版本,也不接管 OpenCode 的服务生命周期,只是发现一个已经装好的opencode2,并通过opencode2 api使用 OpenCode 自己的服务发现机制。因此,"对方装在哪里"这件事天然是不确定的,2.0.17 的三条改动本质上都是在为这种不确定性兜底。

GUI 应用的发现难题:为什么需要一份候选目录清单

从 macOS 的 Finder 或 Dock 启动的 Hex,继承的是 launchd 的PATH,其中通常只有/usr/bin:/bin:/usr/sbin:/sbin这类系统目录,用户通过 npm、pnpm、Homebrew 或版本管理器安装的 CLI 一律不在其中。直接调用Command::new("opencode2")在这种环境下必然失败。

Hex 的解决办法不是去启动一个登录 shell(sh -lc、zsh -ic之类)来"借用"用户的交互式环境,因为那样会引入启动延迟、shell 配置文件副作用,甚至在用户 rc 文件报错时污染 stderr。它改为枚举已知的安装位置并直接检查文件,整个过程中没有任何 shell 参与。

核心常量如下:

const OPENCODE_BINARY_NAMES: [&str; 2] = ["opencode2", "opencode"]; const OPENCODE_PROBE_DEADLINE: Duration = Duration::from_secs(10); const HOME_INSTALL_DIRECTORIES: [&str; 14] = [ ".opencode/bin", ".vite-plus/bin", ".bun/bin", "Library/pnpm", "Library/pnpm/bin", ".yarn/bin", ".config/yarn/global/node_modules/.bin", ".npm-global/bin", ".local/bin", "bin", ".volta/bin", ".asdf/shims", ".local/share/mise/shims", ".nodenv/shims", ];

这里有两个值得注意的细节。第一,Library/pnpm与Library/pnpm/bin同时出现在列表中,正是 2.0.17 对 pnpm 11 的适配:随着 pnpm 版本演进,全局 bin 目录可能落在 pnpm home 根下,也可能落在其bin子目录下,两者都检查可以让不同安装方式都命中。第二,.nodenv/shims列在普通家目录清单中,因为它本质上是一个 shim 目录,不需要像 NVM、fnm 那样遍历多版本。

搜索顺序与覆盖优先级

opencode_candidates决定了候选路径的先后:

path_candidates // 继承的 PATH .chain(home_candidates) // 上表 14 个家目录位置 .chain(version_manager_candidates(home, name)) // NVM / fnm .chain(["/opt/homebrew/bin", "/usr/local/bin"]) // Homebrew 收尾

顺序并非无关紧要。find_opencode_executable会先遍历名为opencode2的所有候选,全部落空后才开始考虑opencode:

for name in OPENCODE_BINARY_NAMES { for candidate in opencode_candidates(name, path, home, current_directory) { if !is_executable(&candidate) { continue; } if name == "opencode2" || is_v2(&candidate) { return OpenCodeInstall::Ready(candidate); } found_v1 = true; } }

也就是说,任何一个位置的opencode2都优先于任何位置的opencode;而opencode这个名字必须"证明自己是 V2"才会被采用。这条规则由测试opencode2_is_preferred_and_only_a_v2_opencode_is_accepted覆盖:即使某个opencode在搜索序上更靠前,只要新出现的opencode2被创建,发现结果就会切换。最终结果被归纳为三种状态:

pub(crate) enum OpenCodeInstall { Ready(PathBuf), // 一个 V2 CLI:opencode2,或已证实为 V2 的 opencode V1Only, // 只有 V1 的 opencode,Hex 永不运行 Missing, }

这两种失败状态在 UI 上分别对应不同的提示与不同的后续动作,逻辑集中在 src/opencode_catalog.rs:Missing给出"Requires OpenCode",V1Only给出"Requires OpenCode V2"并引导安装 V2。相关文案测试包括opencode_missing_copy_offers_setup_without_an_error和opencode_v1_copy_asks_for_v2_with_setup。

节点版本管理器:有界扫描,而不是全盘搜索

真正容易失控的是版本管理器。NVM 和 fnm 会为每个 Node 版本创建独立目录,路径里带着版本号,无法硬编码。2.0.17 的做法是:只读已知根的下一层,并且限制条目数量。

let roots = [ (home.join(".nvm/versions/node"), "bin"), (home.join("Library/Application Support/fnm/node-versions"), "installation/bin"), (home.join(".local/share/fnm/node-versions"), "installation/bin"), (home.join(".fnm/node-versions"), "installation/bin"), ]; roots .into_iter() .flat_map(|(root, bin)| { fs::read_dir(root) .into_iter() .flatten() .flatten() .take(64) .map(move |entry| entry.path().join(bin).join(name)) }) .collect()

三点约束在这里同时生效:read_dir失败时通过flatten()静默跳过(版本管理器没装就只是没有候选);take(64)把每个根的扫描条目上限固定为 64,避免异常目录(例如误把大量文件放进版本目录)导致回退到线性扫描;槽位各不相同(NVM 是<version>/bin,fnm 各平台可能是<version>/installation/bin也可能是<version>/bin),这是同一工具在不同平台布局差异的如实反映。

version_manager_discovery_is_bounded_to_known_roots用一个临时的.nvm/versions/node/v24.4.1/bin/opencode2验证了这条链路:传入的PATH为None,唯一能命中的就是版本管理器目录。这与发布说明里"without launching a shell or searching the wider filesystem"完全对应——既没有 shell 参与,也没有调用which/locate去扫盘。

显式覆盖与相对路径的陷阱

HEX_OPENCODE_CLI是给开发和排查用的显式覆盖,它的语义是"信任并直接使用":

if let Some(configured) = configured { return OpenCodeInstall::Ready(absolute_path(configured, current_directory)); }

注意absolute_path的调用。覆盖值允许是相对路径,但 OpenCode 子进程后续会切换工作目录到 Hex 的隔离工作区(见下一节),因此必须在切换之前把相对路径解析成绝对路径,否则同一个配置在不同调用点会指向不同文件。测试configured_relative_executable_is_resolved_before_the_workspace_changes正是为此存在:传入bin/opencode2与/tmp/hex-launch作为当前目录,期望得到/tmp/hex-launch/bin/opencode2,并且断言覆盖路径不会触发版本探测。

隔离工作区:为什么必须编码目录

Hex 不希望 OpenCode 在用户的项目目录里执行,也不希望把上下文写进用户自己的 OpenCode 工作区,因此为它准备了一个固定位置:

pub fn opencode_workspace() -> Result<PathBuf> { Ok(support_dir()?.join("opencode")) }

support_dir()遵循平台数据目录约定,并允许用HEX_APPLICATION_SUPPORT_DIR覆盖;在 macOS 上通常是~/Library/Application Support/hex/opencode,Hex Test.app会使用独立的hex-test目录以免与已安装版本互相污染(见 src/app_paths.rs)。

工作区路径要同时出现在两个地方:curl的当前目录,以及x-opencode-directory请求头。二者的处理并不相同:

fs::create_dir_all(workspace)?; let workspace = workspace.canonicalize()?; // 解析符号链接、去掉 .. let directory = workspace.to_str() .ok_or_else(|| eyre!("OpenCode workspace path is not valid UTF-8"))?; command.current_dir(&workspace); Some(format!("x-opencode-directory:{}", encode_uri_component(directory)))

canonicalize先做规范化很关键:macOS 上~/Library与/Users/<name>/Library可能通过符号链接互相指向,若把未规范化的路径放进请求头,OpenCode 侧会认为请求来自另一个目录,工作区隔离就失效了。随后encode_uri_component做的是比 RFC 3986 更保守的编码——只保留 ASCII 字母数字,其余字节一律%XX:

if byte.is_ascii_alphanumeric() { encoded.push(char::from(byte)); } else { encoded.push('%'); encoded.push(char::from(b"0123456789ABCDEF"[(byte >> 4) as usize])); encoded.push(char::from(b"0123456789ABCDEF"[(byte & 0x0f) as usize])); }

这段实现直接按字节处理,因此 UTF-8 多字节字符会被逐字节百分号编码,天然避免了对非 ASCII 路径的错误截断。测试catalog_http_decodes_large_responses_with_private_auth_and_workspace_scope使用了一个刻意刁难的目录名scope %2F café(包含空格、%、/和一个带重音的字符),并断言:

  • command.get_current_dir()等于规范化后的工作区;
  • 请求头匹配x-opencode-directory:<百分号编码后的规范化路径>;
  • 任何参数中都不出现明文密码fixture-password,也不出现其 Base64 形式。

最后一点来自凭据处理方式:认证信息通过curl --config -从 stdin 传入,而不是命令行参数或临时文件,同时用--noproxy "*"、--proxy ""、--disable关闭用户 curlrc、代理与重定向,并用--proto "=http"限定协议。这些限制保证了这个"本地服务"调用不会被环境配置意外改道。

恢复路径:安装移动与启动瞬时失败

发布说明第二条的后半句是两个不同的恢复场景,实现上也分属两层。

安装位置移动:指纹缓存自动失效

版本探测要依次尝试--version和api get /api/info,最坏情况是一次完整往返。若每次检查 UI 或按热键都跑一遍,会把延迟直接叠到听写体验上。Hex 因此缓存结论:

type OpenCodeFingerprint = (Option<std::time::SystemTime>, u64); fn opencode_fingerprint(executable: &Path) -> Option<OpenCodeFingerprint> { let metadata = std::fs::metadata(executable).ok()?; Some((metadata.modified().ok(), metadata.len())) }

缓存键是可执行文件路径,缓存值包含(修改时间, 文件长度)的指纹和探测结论。只有当指纹完全一致时才命中缓存,因此:

  • 用户npm i -g升级了 OpenCode,mtime 变化,旧的 V1/V2 判定立即作废;
  • 用户切换了 Node 版本,NVM 路径中的版本号变化,路径本身不同,不会误用旧结论;
  • 可执行文件被删除或被目录替换,metadata失败,缓存自然不命中。

还有一条刻意的策略:只有确定的答案才写入缓存。probe_opencode_generation返回Option<bool>,Some(false)会记录一条ignoring OpenCode V1的 info 日志,超时得到的None则不入缓存,下次目录检查会重新探测。这与发布说明中"recovers when an installation moves"直接对应——发现流程没有"记住坏结果"的粘性。

启动瞬时失败:一次限时重试

OpenCode 的服务由它自己的 CLI 启动,首次调用时可能出现"服务已在起来、但模型尚未就绪"的窗口期。Hex 对这个具体错误做一次重试:

fn retryable_generation_error(message: &str) -> bool { message.starts_with("Model unavailable:") }

重试逻辑在generate_single_model中,最多两轮,且等待过程共享同一个截止时间与取消标志:

for attempt in 0..2 { let remaining = remaining_generation_deadline(started, deadline)?; match post(&data, remaining, cancelled)? { Response::Success { data } => return Ok(data.text), Response::Error { message } if attempt == 0 && retryable_generation_error(&message) => { wait_for_generation_retry(started, deadline, cancelled)?; } Response::Error { message } => { return Err(eyre!("OpenCode generation failed: {message}")); } } }

wait_for_generation_retry以 20 ms 为间隔轮询Duration::from_millis(1_500),每一轮都检查取消标志与总截止时间。这样即使 OpenCode 一直没起来,也不会把一次听写卡住超过既定预算;而generation_retry_wait_observes_cancellation验证了在 30 秒预算下取消会立即返回错误而不是继续等待。

值得注意的是,重试只针对 "Model unavailable:" 这一前缀。像Provider request failed或processor returned empty text这类错误不会触发重试(retryable_generation_error的边界由单元测试断言),避免把真实的上游故障伪装成"再试一次就好"。

子进程边界:输出上限与进程组终止

第三条是三个改动中最容易被低估的一条:如果不限制输出、不终止整个进程组,一个不听话的子进程可以让听写线程永久阻塞。

输出上限

const MAX_OPENCODE_OUTPUT_BYTES: u64 = 16 * 1024 * 1024; fn read_bounded_output(mut output: impl Read, max_bytes: u64) -> Result<Vec<u8>> { let mut bytes = Vec::new(); output.by_ref() .take(max_bytes + 1) .read_to_end(&mut bytes)?; if bytes.len() as u64 > max_bytes { Err(eyre!("opencode2 output exceeded {max_bytes} bytes")) } else { Ok(bytes) } }

take(max_bytes + 1)是"多读一个字节"的经典写法:既能把合法输出完整读完,又能在超限时立刻判定而不是先分配一大块内存再溢出。16 MiB 的上限对模型目录响应足够宽裕——测试里特意构造了包含 2048 个模型的响应,确认超过 256 KiB 的正常响应可以完整解码。

进程组

真正的问题是 Unix 上"杀掉父进程不等于杀掉子进程"。OpenCode CLI 自己可能会拉起服务或子进程,若只对父进程kill(),后代仍持有 stdout/stderr 管道写端,读取线程就永远等不到 EOF。Hex 的应对是把子进程放进独立进程组,出错时对整组发信号:

let mut child = command .process_group(0) // 新进程组,组长 PID 即子进程 PID .stdin(/* ... */) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; // ... 轮询 try_wait,检查 cancelled 与 deadline ... match &outcome { Ok(_) => kill_process_group(child.id()), // 成功也清场 Err(_) => terminate_process_group(&mut child), // 取消或超时 } let stdout = stdout.join(); let stderr = stderr.join();
fn kill_process_group(pid: u32) { // run_command 创建的进程组 ID 就是子进程 PID unsafe { libc::killpg(pid as libc::pid_t, libc::SIGKILL); } } fn terminate_process_group(child: &mut Child) { kill_process_group(child.id()); let _ = child.kill(); let _ = child.wait(); }

两个细节体现了工程上的克制。其一,成功路径也调用了kill_process_group:主进程正常退出后,如果它留下了持有管道的后代,同样会阻塞 join,所以清场不是错误路径专属。其二,先终止、后join的顺序被显式注释说明——"在 join 管道之前拆掉被中止的子进程,让阻塞的读取方与输入写入方看到 EOF,而不是把截止时间拖长"。

等待循环本身也是短周期轮询,取消检查先于超时检查,二者都通过AtomicBool与Instant实现:

loop { match child.try_wait() { Ok(Some(status)) => break Ok(status), Ok(None) => {} Err(error) => break Err(error)?, } if cancelled.load(Ordering::Acquire) { break Err(eyre!("opencode2 {operation} was cancelled")); } if started.elapsed() >= deadline { break Err(eyre!("opencode2 {operation} exceeded {} seconds", deadline.as_secs())); } thread::sleep(Duration::from_millis(20)); }

对应测试很直观:cancelled_generation_process_is_killed_promptly启动一个会持续数秒的命令并预先置取消标志,断言 1 秒内返回was cancelled;descendant_holding_output_cannot_outlive_the_deadline让子进程持有输出管道,在 500 ms 预算下断言整体 2 秒内结束——如果只杀父进程,第二个测试会直接挂住。

Windows 走另一条等价路径:src/dictation_processor.rs 中的cli模块通过 Job Object 管理子进程树,terminate会把整个 job 结束,而不是逐个 PID。这与 Unix 的killpg表达的是同一个约束:取消必须覆盖整棵进程树。

与其他版本的衔接与验证方式

把 2.0.17 放到版本序列里看,它的定位会更清楚:更早的发布说明(例如 docs/releases/2.1.9.md)补上了~/.opencode/bin/opencode2这类官方安装位置,docs/releases/2.1.23.md 又补上 Vite Plus;2.0.17 则覆盖了 Node 生态的另一半——包管理器与版本管理器。后续 docs/features/voice-action.md 中给出的发现流程树,可以看作这条演进路线收敛后的完整形态:

HEX_OPENCODE_CLI set -> Use it as is Otherwise PATH, home install folders, Node version managers, Homebrew ├── Any `opencode2` -> Use it // beats every `opencode` ├── Else an `opencode` whose `--version` is 2.x -> Use it ├── Else an `opencode` printing `0.0.0-…` that answers `api get /api/info` -> Use it ├── Only V1 `opencode` -> "Requires OpenCode V2" // never run for generation └── Nothing -> "Requires OpenCode" // both rechecked every 60 s

如果你要自行验证这些行为,仓库里可用的抓手是这些不带外部依赖的单元测试(它们用临时可执行脚本而不是真实安装的 CLI,因此可离线重复运行):

  • standard_install_location_finds_opencode_outside_the_gui_path:在PATH=/usr/bin:/bin下仍能命中.opencode/bin、.vite-plus/bin、Library/pnpm/bin;
  • version_manager_discovery_is_bounded_to_known_roots:NVM 目录发现;
  • opencode2_is_preferred_and_only_a_v2_opencode_is_accepted:opencode2优先级与 V1 拒绝;
  • release_versions_tell_the_generations_apart:opencode v2.2.0、2.10.1、1.19.5、0.0.0-snapshot-pnpm-…等版本字符串的判定;
  • catalog_http_decodes_large_responses_with_private_auth_and_workspace_scope:工作区编码、凭据不上命令行、大响应解码;
  • configured_relative_executable_is_resolved_before_the_workspace_changes:覆盖路径的早期绝对化;
  • cancelled_generation_process_is_killed_promptly与descendant_holding_output_cannot_outlive_the_deadline:取消与后代进程的时限保证。

需要说明的是,这些测试验证的是 Hex 侧的契约与边界,并不等同于对某个具体 OpenCode 版本的provider 端到端验证;docs/opencode-compatibility.md 明确要求新的带 provider 的验证必须自行记录 CLI 与服务端的版本号。

小结

2.0.17 是一条典型的"发布说明三行、实现牵一发动全身"的补丁。把它的三条改动串起来,可以归纳成三个可复用的工程原则:

  1. GUI 应用不能假设PATH可用,也不该通过启动登录 shell 去借环境;枚举已知安装位置 + 直接文件系统检查,才是有界且可预测的做法;对版本管理器这种层级不固定的目录,必须限定根与条目数。
  2. 工作区隔离依赖路径的一致性:先create_dir_all、再canonicalize、再做逐字节百分号编码,三者缺一都会让x-opencode-directory与curl实际的工作目录产生偏差;而缓存探测结论时必须绑定文件指纹,才能让"安装被移动/升级"这种变化自动生效。
  3. 每个外部子进程都需要一个可收回的边界:输出上限防止内存与阻塞双失控,独立进程组 +SIGKILL保证取消能覆盖后代,而"先终止再 join"的顺序决定了截止时间是否真的有效。

这些机制集中在 src/dictation_processor.rs 一个文件里,若你想继续深入,建议按"发现 → 工作区 → 生成 → 进程回收"的顺序阅读,并配合 src/opencode_catalog.rs 观察发现结论如何影响 Voice Action 与 Modes 的可用性提示。

  • 语音
  • 桌面应用
  • 本地部署
  • 人工智能
  • 音频

【免费下载链接】hex

Local voice dictation for mac, windows and linux

项目地址:https://gitcode.com/GitHub_Trending/hex19/hex
点击查看免费下载

相关推荐

上一篇:AMD Phi-4-mini-instruct_rai_1.8.0_npu_16K 完整指南:Ryzen AI NPU 本地大模型是什么,为什么值得关注?
下一篇:运行DeepSeek-V4-Flash-GGUF常见问题与解决方案:从OOM到模板异常完整排错清单

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询