Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f107358
feat(windows): 接通共享 lithe-agent-host 的 Agent 连接桥
puppyben1 Sep 28, 2026
2caa6ce
fix(windows): 收敛 Agent 连接桥的退出等待与窗口归属
puppyben1 Sep 29, 2026
a93558c
Merge remote-tracking branch 'origin/preview' into codex/issue-956-wi…
puppyben1 Sep 29, 2026
2db7192
test(windows): 用受控假适配器验证 Agent 进程树回收
puppyben1 Sep 29, 2026
bc66c2c
fix(windows): 让 Agent 进程树测试按需构建假适配器
puppyben1 Sep 29, 2026
44016eb
feat(windows): 转发 Agent 安装进度并取消安装信封超时
puppyben1 Sep 29, 2026
b4da1b2
feat(windows): 新增 Agent 侧栏面板、供应商设置与能力开关
puppyben1 Sep 29, 2026
110f719
docs(matrix): 记录 Windows Agent 面板的实现状态
puppyben1 Sep 29, 2026
5c62656
ci(windows): 让前端流水线跑 Agent 面板与能力开关测试
puppyben1 Sep 29, 2026
d8ffe22
feat(windows): 历史会话筛选、搜索、收藏与本地重命名
puppyben1 Sep 29, 2026
f8cb3f5
feat(windows): 导出历史会话为 Markdown
puppyben1 Sep 29, 2026
41e993c
feat(windows): Agent 输入框支持拖拽与 @ 引用项目文件
puppyben1 Oct 1, 2026
730f3f7
feat(windows): Agent 设置支持 CLI 安装升级与 CLI 配置导入
puppyben1 Oct 1, 2026
6bc0e22
Merge remote-tracking branch 'origin/preview' (8844274c) into codex/i…
puppyben1 Oct 1, 2026
29dcbdb
docs(matrix): 记录 Windows Agent 的 CLI 安装升级、配置导入与拖拽/@ 引用
puppyben1 Oct 1, 2026
88278e3
Merge remote-tracking branch 'origin/preview' (7cb138b9) into codex/i…
puppyben1 Oct 1, 2026
2061488
fix(windows): Agent 输入框接收系统与项目树的真实拖放
puppyben1 Oct 1, 2026
4745775
fix(agent-host): Windows 上 npm 安装的 CLI 可以启动会话并识别为可升级
puppyben1 Oct 1, 2026
b16e085
feat(windows): Agent 面板按 macOS 一比一重做对话区、输入框与面板骨架
puppyben1 Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .github/workflows/ci-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ jobs:
bun test src/features/file-explorer/lib/java-clipboard-class.test.ts src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts
node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-file-watcher.json -- src/features/file-system/services/file-watcher-listener.test.ts src/features/file-system/stores/file-watcher.store.test.ts

- name: Test Agent panel and capability gate
shell: pwsh
run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-agent.json -- src/features/agent src/config/backend-capabilities.test.ts src/features/file-system/utils/file-system-drop-controller.test.ts src/ui/icons.test.tsx

- name: Test editor tab scrolling and lifecycle
shell: pwsh
run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-tabs.json -- src/features/tabs
Expand Down
22 changes: 11 additions & 11 deletions docs/development/platform-parity-matrix.csv

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions docs/development/platform-parity-matrix.md

Large diffs are not rendered by default.

90 changes: 69 additions & 21 deletions rust/lithe-agent-host/src/cli_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,16 @@ fn resolve(
));
}
}
let npm_owned = target.as_ref().is_some_and(|target| {
target.ancestors().any(|root| {
// The file the npm package declares: the link target on Unix, or the bin
// behind a Windows `.cmd` launcher, which does not resolve to it.
let package_bin = executable
.and_then(|executable| npm_launcher_bin(cli, executable))
.map(|(_, canonical)| canonical)
.or_else(|| target.clone());
let npm_owned = package_bin.as_ref().is_some_and(|package_bin| {
package_bin.ancestors().any(|root| {
root.file_name().is_some_and(|name| name == "node_modules")
&& owns_npm_bin(&root.join(cli.package), cli, target)
&& owns_npm_bin(&root.join(cli.package), cli, package_bin)
})
});
let source = if executable.is_none() {
Expand Down Expand Up @@ -257,7 +263,9 @@ fn resolve(
if !package
.canonicalize()
.is_ok_and(|package| package.starts_with(&root))
|| !owns_npm_bin(&package, cli, target)
|| !package_bin
.as_ref()
.is_some_and(|package_bin| owns_npm_bin(&package, cli, package_bin))
|| bin.canonicalize().ok().as_ref() != Some(target)
{
return Ok(UpdatePlan::manual(CliSource::Npm,
Expand All @@ -283,33 +291,73 @@ fn resolve(
}

fn owns_npm_bin(package: &Path, cli: &AgentCli, target: &Path) -> bool {
npm_package_bin(package, cli).is_some_and(|(_, bin)| bin == target)
}

/// The CLI's bin declared by the npm package at `package`, as written and
/// canonical. `None` unless the manifest names this CLI's package and the bin
/// stays inside it, so a foreign package can never claim the command.
fn npm_package_bin(package: &Path, cli: &AgentCli) -> Option<(PathBuf, PathBuf)> {
let manifest = (|| {
let file = std::fs::File::open(package.join("package.json")).ok()?;
let mut bytes = Vec::new();
file.take(64 * 1024).read_to_end(&mut bytes).ok()?;
serde_json::from_slice::<serde_json::Value>(&bytes).ok()
})();
let Some(manifest) = manifest else {
return false;
};
})()?;
if manifest["name"] != cli.package {
return false;
return None;
}
let Some(bin) = manifest["bin"]
let bin = manifest["bin"]
.get(cli.command)
.unwrap_or(&manifest["bin"])
.as_str()
else {
return false;
};
let Ok(package_root) = package.canonicalize() else {
return false;
};
package
.join(bin)
.canonicalize()
.is_ok_and(|bin| bin.starts_with(package_root) && bin == target)
.as_str()?;
let package_root = package.canonicalize().ok()?;
let declared = package.join(bin);
let canonical = declared.canonicalize().ok()?;
canonical
.starts_with(package_root)
.then_some((declared, canonical))
}

/// Package bin behind an npm `<command>.cmd` launcher. npm on Windows writes a
/// batch launcher into the global prefix instead of a symlink, so neither the
/// launcher's path nor its canonical form leads to the package; its package is
/// always `node_modules/<package>` next to it. Other platforms never select a
/// `.cmd` file, so this is a no-op there.
fn npm_launcher_bin(cli: &AgentCli, launcher: &Path) -> Option<(PathBuf, PathBuf)> {
let is_launcher = launcher
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("cmd"))
&& launcher
.file_stem()
.is_some_and(|stem| stem.eq_ignore_ascii_case(cli.command));
if !is_launcher {
return None;
}
npm_package_bin(
&launcher.parent()?.join("node_modules").join(cli.package),
cli,
)
}

/// Executable an adapter should start for the detected CLI.
///
/// Adapters spawn this path without a shell, and Node.js refuses to start a
/// `.cmd` file that way on Windows (`spawn EINVAL`). An npm launcher whose
/// package ships a native executable is therefore replaced by that executable;
/// script bins keep the launcher, which needs Node.js to run anyway.
pub(crate) fn launch_executable(cli: &AgentCli, detected: &Path) -> PathBuf {
npm_launcher_bin(cli, detected)
.map(|(declared, _)| declared)
.filter(|bin| {
bin.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
})
.unwrap_or_else(|| detected.to_path_buf())
}

#[cfg(all(test, unix))]
mod tests;

#[cfg(test)]
mod launcher_tests;
133 changes: 133 additions & 0 deletions rust/lithe-agent-host/src/cli_update/launcher_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! npm's Windows `.cmd` launchers. The layout is plain files, so the launcher
//! mapping runs on every platform; ownership resolution needs Windows paths.

use super::*;

struct Prefix(PathBuf);

impl Prefix {
/// An npm global prefix holding `<command>.cmd` and the package that owns it.
fn new(cli: &AgentCli, package_name: &str, bin: &str) -> Self {
let root =
std::env::temp_dir().join(format!("lithe-cli-launcher-{}", uuid::Uuid::new_v4()));
let package = root.join("node_modules").join(cli.package);
std::fs::create_dir_all(package.join("bin")).unwrap();
std::fs::write(
package.join("package.json"),
serde_json::json!({ "name": package_name, "bin": { cli.command: bin } }).to_string(),
)
.unwrap();
std::fs::write(package.join(bin), "binary").unwrap();
std::fs::write(root.join(format!("{}.cmd", cli.command)), "@ECHO off\r\n").unwrap();
Self(root)
}

fn launcher(&self, cli: &AgentCli) -> PathBuf {
self.0.join(format!("{}.cmd", cli.command))
}
}

impl Drop for Prefix {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}

fn cli(agent: &str) -> &'static AgentCli {
crate::catalog::find(agent).unwrap().cli.as_ref().unwrap()
}

#[test]
fn native_npm_bin_replaces_its_launcher_so_adapters_can_spawn_it() {
// Claude Code ships `bin/claude.exe`; Node.js cannot spawn `claude.cmd`
// without a shell, which surfaced as `spawn EINVAL` on the first session.
let claude = cli("claude-acp");
let prefix = Prefix::new(claude, claude.package, "bin/claude.exe");
assert_eq!(
launch_executable(claude, &prefix.launcher(claude)),
prefix
.0
.join("node_modules")
.join(claude.package)
.join("bin/claude.exe")
);
}

#[test]
fn script_bins_and_foreign_packages_keep_the_detected_launcher() {
// Codex ships `bin/codex.js`, which still needs the launcher to find Node.js.
let codex = cli("codex-acp");
let script = Prefix::new(codex, codex.package, "bin/codex.js");
assert_eq!(
launch_executable(codex, &script.launcher(codex)),
script.launcher(codex)
);

// A package that is not the CLI's own never redirects the launch.
let claude = cli("claude-acp");
let foreign = Prefix::new(claude, "unrelated-package", "bin/claude.exe");
assert_eq!(
launch_executable(claude, &foreign.launcher(claude)),
foreign.launcher(claude)
);

// Paths that are not npm launchers are passed through unchanged.
let plain = PathBuf::from("/usr/local/bin/claude");
assert_eq!(launch_executable(claude, &plain), plain);
}

#[cfg(windows)]
#[test]
fn npm_cmd_launcher_is_recognised_as_an_updatable_npm_install() {
for (agent, bin) in [
("codex-acp", "bin/codex.js"),
("claude-acp", "bin/claude.exe"),
] {
let cli = cli(agent);
let prefix = Prefix::new(cli, cli.package, bin);
let npm = prefix.0.join("npm.cmd");
std::fs::write(&npm, "@ECHO off\r\n").unwrap();
let plan = resolve(
cli,
Some(&prefix.launcher(cli)),
Some(&npm),
None,
None,
&|_, args| match args {
["prefix", "--global"] => Ok(prefix.0.display().to_string()),
["root", "--global"] => Ok(prefix.0.join("node_modules").display().to_string()),
_ => panic!("unexpected probe: {args:?}"),
},
)
.unwrap();
assert_eq!(plan.installation.source, CliSource::Npm, "{agent}");
assert!(plan.installation.can_update, "{agent}");
assert_eq!(plan.command.unwrap().program, npm);
}
}

#[cfg(windows)]
#[test]
fn a_launcher_from_another_npm_prefix_is_never_updated_by_the_active_npm() {
let codex = cli("codex-acp");
let active = Prefix::new(codex, codex.package, "bin/codex.js");
let other = Prefix::new(codex, codex.package, "bin/codex.js");
let npm = active.0.join("npm.cmd");
std::fs::write(&npm, "@ECHO off\r\n").unwrap();
let plan = resolve(
codex,
Some(&other.launcher(codex)),
Some(&npm),
None,
None,
&|_, args| match args {
["prefix", "--global"] => Ok(active.0.display().to_string()),
["root", "--global"] => Ok(active.0.join("node_modules").display().to_string()),
_ => panic!("unexpected probe: {args:?}"),
},
)
.unwrap();
assert_eq!(plan.installation.source, CliSource::Npm);
assert!(!plan.installation.can_update);
assert!(plan.command.is_none());
}
13 changes: 12 additions & 1 deletion rust/lithe-agent-host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ const PERMISSION_TIMEOUT: Duration = Duration::from_secs(300);
const CANCEL_TIMEOUT: Duration = Duration::from_secs(10);
/// Upper bound on `session/list` pages so a misbehaving cursor cannot loop forever.
const MAX_SESSION_LIST_PAGES: usize = 50;
/// Windows flag that runs the adapter with a console it cannot display. The
/// desktop shell is a GUI process without a console, so a console-subsystem
/// adapter would otherwise open a visible console window on every launch.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Bytes of agent stderr kept for failure reports; older output is discarded.
const STDERR_TAIL_BYTES: usize = 16 * 1024;
const STDERR_TAIL_LINES: usize = 20;
Expand Down Expand Up @@ -276,7 +281,9 @@ fn resolve_with(
if let Some(tool) = detected {
env.push((
cli.path_env.to_owned(),
tool.path.to_string_lossy().into_owned(),
cli_update::launch_executable(cli, &tool.path)
.to_string_lossy()
.into_owned(),
));
}
}
Expand Down Expand Up @@ -703,6 +710,10 @@ async fn run_agent(
command.process_group(0);
}
let mut command = tokio::process::Command::from(command);
#[cfg(windows)]
{
command.creation_flags(CREATE_NO_WINDOW);
}
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
Expand Down
4 changes: 3 additions & 1 deletion rust/lithe-agent-host/src/subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ pub(super) fn resolve(
env: vec![
(
cli.path_env.into(),
executable.to_string_lossy().into_owned(),
crate::cli_update::launch_executable(cli, &executable)
.to_string_lossy()
.into_owned(),
),
("MODEL_PROVIDER".into(), "openai".into()),
(
Expand Down
Loading