Add screen orientation polyfill, refactor provider launch config, and update desktop runtime manager

- Add .claude schedule tasks to .gitignore
- Add screen-orientation polyfill for Expo web
- Refactor agent provider launch config into shared utility
- Update desktop runtime manager with improved binary management
- Update desktop release workflow
This commit is contained in:
Mohamed Boudra
2026-03-11 14:01:07 +07:00
parent acfb933ee8
commit eb5f011161
13 changed files with 264 additions and 53 deletions
+1 -1
View File
@@ -521,7 +521,7 @@ jobs:
releaseBody: See the assets to download and install this version. releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }} releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false prerelease: false
args: --bundles nsis,msi args: --bundles nsis
- name: Build Windows app (smoke only) - name: Build Windows app (smoke only)
if: env.IS_SMOKE_TAG == 'true' if: env.IS_SMOKE_TAG == 'true'
+2
View File
@@ -71,6 +71,8 @@ valknut-report.json/
**/.paseo-provider-history/ **/.paseo-provider-history/
.claude/settings.local.json .claude/settings.local.json
**/.claude/settings.local.json **/.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees/
.plans/ .plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
+10
View File
@@ -9138,6 +9138,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-log": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz",
"integrity": "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tsconfig/node10": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -27589,6 +27598,7 @@
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21", "@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1", "@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
+11
View File
@@ -2,6 +2,17 @@
import { polyfillCrypto } from "./src/polyfills/crypto"; import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto(); polyfillCrypto();
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation();
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
attachConsole();
});
}
// Configure Unistyles before Expo Router pulls in any components using StyleSheet. // Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles"; import "./src/styles/unistyles";
import "expo-router/entry"; import "expo-router/entry";
+1
View File
@@ -53,6 +53,7 @@
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21", "@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1", "@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
@@ -0,0 +1,28 @@
export function polyfillScreenOrientation() {
if (
typeof window === "undefined" ||
typeof screen === "undefined" ||
screen.orientation
) {
return;
}
Object.defineProperty(screen, "orientation", {
value: {
get type() {
return window.innerWidth > window.innerHeight
? "landscape-primary"
: "portrait-primary";
},
get angle() {
return 0;
},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
},
configurable: true,
});
}
+1 -1
View File
@@ -2629,7 +2629,7 @@ dependencies = [
[[package]] [[package]]
name = "paseo" name = "paseo"
version = "0.1.24" version = "0.1.25"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs", "dirs",
+22 -2
View File
@@ -158,6 +158,7 @@ fn maybe_run_managed_headless_command(app: &AppHandle) -> Result<bool, String> {
let Some(command) = parse_managed_headless_command()? else { let Some(command) = parse_managed_headless_command()? else {
return Ok(false); return Ok(false);
}; };
log::info!("[headless] running managed headless command: {:?}", command);
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.hide(); let _ = window.hide();
} }
@@ -625,17 +626,36 @@ pub fn run() {
garbage_collect_attachment_files garbage_collect_attachment_files
]) ])
.setup(|app| { .setup(|app| {
log::info!(
"[app] Paseo Desktop v{} starting",
app.package_info().version
);
if maybe_run_managed_headless_command(app.handle())? { if maybe_run_managed_headless_command(app.handle())? {
return Ok(()); return Ok(());
} }
if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
tauri_plugin_log::Builder::default() tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info) .level(log::LevelFilter::Info)
.level_for("tao", log::LevelFilter::Warn)
.level_for("wry", log::LevelFilter::Warn)
.max_file_size(5_000_000)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.clear_targets()
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir {
file_name: Some("app.log".into()),
},
))
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Stdout,
))
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Webview,
))
.build(), .build(),
)?; )?;
}
// Start from Tauri's default menu so macOS standard shortcuts (Cmd+A/C/V/etc) // Start from Tauri's default menu so macOS standard shortcuts (Cmd+A/C/V/etc)
// keep working. Then inject our zoom controls into a View menu. // keep working. Then inject our zoom controls into a View menu.
@@ -563,13 +563,20 @@ fn bundled_runtime_root(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(resource_dir) = app.path().resource_dir() { if let Ok(resource_dir) = app.path().resource_dir() {
let candidate = resource_dir.join("managed-runtime"); let candidate = resource_dir.join("managed-runtime");
if candidate.exists() { if candidate.exists() {
log::info!("[runtime] found bundled runtime at {}", candidate.display());
return Ok(candidate); return Ok(candidate);
} }
log::info!(
"[runtime] no bundled runtime at {}, checking dev path",
candidate.display()
);
} }
let dev = dev_resource_root().join("managed-runtime"); let dev = dev_resource_root().join("managed-runtime");
if dev.exists() { if dev.exists() {
log::info!("[runtime] using dev runtime at {}", dev.display());
return Ok(dev); return Ok(dev);
} }
log::error!("[runtime] no managed runtime found");
Err("Managed runtime resources are not bundled with this desktop build.".to_string()) Err("Managed runtime resources are not bundled with this desktop build.".to_string())
} }
@@ -715,17 +722,25 @@ fn cli_command(
let node = runtime_root.join(&manifest.node_relative_path); let node = runtime_root.join(&manifest.node_relative_path);
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path); let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path);
if !node.exists() { if !node.exists() {
log::error!("[cli] bundled Node missing at {}", node.display());
return Err(format!( return Err(format!(
"Bundled Node runtime is missing at {}", "Bundled Node runtime is missing at {}",
node.display() node.display()
)); ));
} }
if !cli.exists() { if !cli.exists() {
log::error!("[cli] bundled CLI missing at {}", cli.display());
return Err(format!( return Err(format!(
"Bundled CLI entrypoint is missing at {}", "Bundled CLI entrypoint is missing at {}",
cli.display() cli.display()
)); ));
} }
log::info!(
"[cli] node={} cli={} args={:?}",
node.display(),
cli.display(),
args
);
let mut command = Command::new(node); let mut command = Command::new(node);
command.arg(cli); command.arg(cli);
command.args(args); command.args(args);
@@ -893,10 +908,22 @@ fn write_state_file(path: &Path, value: &ManagedStateFile) -> Result<(), String>
} }
fn ensure_runtime_ready_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus, String> { fn ensure_runtime_ready_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus, String> {
log::info!("[runtime] ensuring runtime is ready");
let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?; let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?;
let runtime_root = bundled_root.join(&pointer.relative_root); let runtime_root = bundled_root.join(&pointer.relative_root);
let paths = resolve_paths(app)?; let paths = resolve_paths(app)?;
log::info!(
"[runtime] runtime_root={} managed_home={} transport={}",
runtime_root.display(),
paths.managed_home.display(),
paths.transport_path.display()
);
let manifest = load_runtime_manifest(&runtime_root)?; let manifest = load_runtime_manifest(&runtime_root)?;
log::info!(
"[runtime] manifest: id={} version={}",
manifest.runtime_id,
manifest.runtime_version
);
if let Some(parent) = paths.transport_path.parent() { if let Some(parent) = paths.transport_path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
@@ -905,6 +932,11 @@ fn ensure_runtime_ready_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus
.map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?; .map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?;
let existing_state = read_state_file(&paths.state_file_path); let existing_state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(&paths, existing_state.as_ref())?; let target = managed_transport_target(&paths, existing_state.as_ref())?;
log::info!(
"[runtime] transport target: type={} path={}",
target.transport_type,
target.transport_path
);
let state = ManagedStateFile { let state = ManagedStateFile {
runtime_id: manifest.runtime_id.clone(), runtime_id: manifest.runtime_id.clone(),
runtime_root: runtime_root.to_string_lossy().into_owned(), runtime_root: runtime_root.to_string_lossy().into_owned(),
@@ -940,20 +972,32 @@ fn run_cli_json_command(
args: &[&str], args: &[&str],
paths: &ManagedPaths, paths: &ManagedPaths,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
log::info!("[cli] running: {:?}", args);
let output = cli_command(runtime_root, manifest, args, paths)? let output = cli_command(runtime_root, manifest, args, paths)?
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.output() .output()
.map_err(|error| format!("Failed to run bundled CLI: {error}"))?; .map_err(|error| {
log::error!("[cli] failed to spawn: {error}");
format!("Failed to run bundled CLI: {error}")
})?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::error!(
"[cli] exit={} stderr={}",
output.status.code().unwrap_or(-1),
stderr.trim()
);
return Err(format!( return Err(format!(
"Bundled CLI failed (exit {}): {}", "Bundled CLI failed (exit {}): {}",
output.status.code().unwrap_or(-1), output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim() stderr.trim()
)); ));
} }
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
log::info!("[cli] success, parsing JSON output");
serde_json::from_str(stdout.trim()).map_err(|error| { serde_json::from_str(stdout.trim()).map_err(|error| {
log::error!("[cli] JSON parse error: {error}; stdout={}", stdout.trim());
format!( format!(
"Failed to parse bundled CLI JSON output: {error}; stdout={}", "Failed to parse bundled CLI JSON output: {error}; stdout={}",
stdout.trim() stdout.trim()
@@ -1101,6 +1145,7 @@ fn remove_cli_shim_via_macos_prompt(shim_path: &Path) -> Result<(), String> {
} }
fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> { fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
log::info!("[cli-shim] installing CLI shim");
let status = ensure_runtime_ready_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app)?; let paths = resolve_paths(app)?;
let runtime_root = PathBuf::from(&status.runtime_root); let runtime_root = PathBuf::from(&status.runtime_root);
@@ -1116,6 +1161,7 @@ fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
#[cfg(windows)] #[cfg(windows)]
let install_result: Result<(), String> = Err("AUTOMATIC_INSTALL_UNAVAILABLE".to_string()); let install_result: Result<(), String> = Err("AUTOMATIC_INSTALL_UNAVAILABLE".to_string());
log::info!("[cli-shim] target path: {}", shim_path.display());
match install_result { match install_result {
Ok(()) => { Ok(()) => {
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
@@ -1209,10 +1255,15 @@ fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String>
} }
fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
log::info!("[daemon] starting managed daemon");
let status = ensure_runtime_ready_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app)?; let paths = resolve_paths(app)?;
let existing_status = managed_daemon_status_internal(app)?; let existing_status = managed_daemon_status_internal(app)?;
if existing_status.daemon_running { if existing_status.daemon_running {
log::info!(
"[daemon] already running (pid={:?})",
existing_status.daemon_pid
);
return Ok(existing_status); return Ok(existing_status);
} }
if let Some(parent) = paths.transport_path.parent() { if let Some(parent) = paths.transport_path.parent() {
@@ -1223,6 +1274,12 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
let manifest = load_runtime_manifest(&runtime_root)?; let manifest = load_runtime_manifest(&runtime_root)?;
let state = read_state_file(&paths.state_file_path); let state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(&paths, state.as_ref())?; let target = managed_transport_target(&paths, state.as_ref())?;
log::info!(
"[daemon] spawning: home={} listen={} (type={})",
paths.managed_home.display(),
target.transport_path,
target.transport_type
);
let output = cli_command( let output = cli_command(
&runtime_root, &runtime_root,
&manifest, &manifest,
@@ -1238,25 +1295,42 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.output() .output()
.map_err(|error| format!("Failed to launch managed daemon: {error}"))?; .map_err(|error| {
log::error!("[daemon] failed to spawn: {error}");
format!("Failed to launch managed daemon: {error}")
})?;
if !output.status.success() { if !output.status.success() {
let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr)));
log::error!(
"[daemon] start failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr
);
return Err(format!( return Err(format!(
"Managed daemon start failed (exit {}): {}", "Managed daemon start failed (exit {}): {}",
output.status.code().unwrap_or(-1), output.status.code().unwrap_or(-1),
to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))) stderr
)); ));
} }
for _ in 0..30 { log::info!("[daemon] start command succeeded, waiting for daemon to be ready");
for attempt in 0..30 {
let daemon_status = managed_daemon_status_internal(app)?; let daemon_status = managed_daemon_status_internal(app)?;
if daemon_status.daemon_running { if daemon_status.daemon_running {
log::info!(
"[daemon] ready after {} attempts (pid={:?})",
attempt + 1,
daemon_status.daemon_pid
);
return Ok(daemon_status); return Ok(daemon_status);
} }
std::thread::sleep(std::time::Duration::from_millis(200)); std::thread::sleep(std::time::Duration::from_millis(200));
} }
log::warn!("[daemon] timed out waiting for daemon to become ready");
managed_daemon_status_internal(app) managed_daemon_status_internal(app)
} }
fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
log::info!("[daemon] stopping managed daemon");
let status = ensure_runtime_ready_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app)?; let paths = resolve_paths(app)?;
let runtime_root = PathBuf::from(&status.runtime_root); let runtime_root = PathBuf::from(&status.runtime_root);
@@ -1276,24 +1350,40 @@ fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.output() .output()
.map_err(|error| format!("Failed to stop managed daemon: {error}"))?; .map_err(|error| {
log::error!("[daemon] failed to spawn stop command: {error}");
format!("Failed to stop managed daemon: {error}")
})?;
if !output.status.success() { if !output.status.success() {
let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr)));
log::error!(
"[daemon] stop failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr
);
return Err(format!( return Err(format!(
"Managed daemon stop failed (exit {}): {}", "Managed daemon stop failed (exit {}): {}",
output.status.code().unwrap_or(-1), output.status.code().unwrap_or(-1),
to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))) stderr
)); ));
} }
log::info!("[daemon] stop command succeeded");
managed_daemon_status_internal(app) managed_daemon_status_internal(app)
} }
fn restart_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn restart_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
log::info!("[daemon] restarting managed daemon");
let status = ensure_runtime_ready_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app)?; let paths = resolve_paths(app)?;
let runtime_root = PathBuf::from(&status.runtime_root); let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?; let manifest = load_runtime_manifest(&runtime_root)?;
let state = read_state_file(&paths.state_file_path); let state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(&paths, state.as_ref())?; let target = managed_transport_target(&paths, state.as_ref())?;
log::info!(
"[daemon] restart: listen={} (type={})",
target.transport_path,
target.transport_type
);
let output = cli_command( let output = cli_command(
&runtime_root, &runtime_root,
&manifest, &manifest,
@@ -1311,14 +1401,24 @@ fn restart_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatu
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.output() .output()
.map_err(|error| format!("Failed to restart managed daemon: {error}"))?; .map_err(|error| {
log::error!("[daemon] failed to spawn restart command: {error}");
format!("Failed to restart managed daemon: {error}")
})?;
if !output.status.success() { if !output.status.success() {
let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr)));
log::error!(
"[daemon] restart failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr
);
return Err(format!( return Err(format!(
"Managed daemon restart failed (exit {}): {}", "Managed daemon restart failed (exit {}): {}",
output.status.code().unwrap_or(-1), output.status.code().unwrap_or(-1),
to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))) stderr
)); ));
} }
log::info!("[daemon] restart command succeeded");
managed_daemon_status_internal(app) managed_daemon_status_internal(app)
} }
@@ -1326,6 +1426,12 @@ fn update_managed_tcp_settings_internal(
app: &AppHandle, app: &AppHandle,
settings: ManagedTcpSettings, settings: ManagedTcpSettings,
) -> Result<ManagedDaemonStatus, String> { ) -> Result<ManagedDaemonStatus, String> {
log::info!(
"[tcp] updating settings: enabled={} host={} port={}",
settings.enabled,
settings.host,
settings.port
);
if settings.enabled { if settings.enabled {
parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?; parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?;
} }
@@ -1596,6 +1702,12 @@ pub async fn open_local_daemon_transport(
transport_path: String, transport_path: String,
) -> Result<String, String> { ) -> Result<String, String> {
let session_id = transport_state.alloc_session_id(); let session_id = transport_state.alloc_session_id();
log::info!(
"[transport] opening session {} type={} path={}",
session_id,
transport_type,
transport_path
);
let _ = app; let _ = app;
match transport_type.as_str() { match transport_type.as_str() {
"pipe" => { "pipe" => {
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process"; import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { platform } from "node:os";
import { z } from "zod"; import { z } from "zod";
import type { AgentProvider } from "./agent-sdk-types.js"; import type { AgentProvider } from "./agent-sdk-types.js";
@@ -88,24 +89,63 @@ export function applyProviderEnv(
}; };
} }
export function isCommandAvailable(command: string): boolean { /**
const normalized = command.trim(); * Resolve an executable name to its absolute path the way the user's shell would.
if (!normalized) { *
return false; * On Unix we first try `$SHELL -lic "which <name>"` so that rc-file PATH
* additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the
* user opened a terminal and typed the command. If that fails (e.g. the login
* shell itself errors) we fall back to a plain `which`.
*
* On Windows the system PATH is always available, so `where.exe` is sufficient.
*/
export function findExecutable(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) {
return null;
} }
if (normalized.includes("/") || normalized.includes("\\")) { if (trimmed.includes("/") || trimmed.includes("\\")) {
return existsSync(normalized); return existsSync(trimmed) ? trimmed : null;
}
if (platform() === "win32") {
try {
const out = execSync(`where.exe ${trimmed}`, { encoding: "utf8" }).trim();
const firstLine = out.split(/\r?\n/)[0]?.trim();
return firstLine || null;
} catch {
return null;
}
}
// Unix: try the user's login shell so rc-file PATH entries are visible.
const shell = process.env["SHELL"];
if (shell) {
try {
const out = execSync(`${shell} -lic "which ${trimmed}"`, {
encoding: "utf8",
timeout: 5000,
}).trim();
if (out) {
return out;
}
} catch {
// Login shell failed (broken rc, etc.) — fall through to plain which.
}
} }
try { try {
const output = execFileSync("which", [normalized], { encoding: "utf8" }).trim(); return execFileSync("which", [trimmed], { encoding: "utf8" }).trim() || null;
return output.length > 0;
} catch { } catch {
return false; return null;
} }
} }
export function isCommandAvailable(command: string): boolean {
return findExecutable(command) !== null;
}
export function isProviderCommandAvailable( export function isProviderCommandAvailable(
commandConfig: ProviderCommand | undefined, commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string resolveDefaultCommand: () => string
@@ -69,6 +69,7 @@ import type {
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { import {
applyProviderEnv, applyProviderEnv,
findExecutable,
isProviderCommandAvailable, isProviderCommandAvailable,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
@@ -363,16 +364,8 @@ type ClaudeAgentSessionOptions = {
logger: Logger; logger: Logger;
}; };
function whichClaude(): string | null {
try {
return execSync("which claude", { encoding: "utf8" }).trim() || null;
} catch {
return null;
}
}
function resolveClaudeBinary(): string { function resolveClaudeBinary(): string {
const claudePath = whichClaude(); const claudePath = findExecutable("claude");
if (claudePath) { if (claudePath) {
return claudePath; return claudePath;
} }
@@ -1414,7 +1407,7 @@ export class ClaudeAgentClient implements AgentClient {
this.defaults = options.defaults; this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" }); this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
this.claudePath = whichClaude(); this.claudePath = findExecutable("claude");
if (this.claudePath) { if (this.claudePath) {
try { try {
const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim(); const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim();
@@ -40,6 +40,7 @@ import {
} from "./codex/tool-call-mapper.js"; } from "./codex/tool-call-mapper.js";
import { import {
applyProviderEnv, applyProviderEnv,
findExecutable,
isProviderCommandAvailable, isProviderCommandAvailable,
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
@@ -150,14 +151,10 @@ function mergeCodexConfiguredDefaults(
} }
function resolveCodexBinary(): string { function resolveCodexBinary(): string {
try { const codexPath = findExecutable("codex");
const codexPath = execSync("which codex", { encoding: "utf8" }).trim();
if (codexPath) { if (codexPath) {
return codexPath; return codexPath;
} }
} catch {
// Fall through to error
}
throw new Error( throw new Error(
"Codex CLI not found. Please install codex globally: npm install -g @openai/codex" "Codex CLI not found. Please install codex globally: npm install -g @openai/codex"
); );
@@ -1,4 +1,4 @@
import { execSync, spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
import net from "node:net"; import net from "node:net";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -29,6 +29,7 @@ import type {
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { import {
applyProviderEnv, applyProviderEnv,
findExecutable,
isProviderCommandAvailable, isProviderCommandAvailable,
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
@@ -150,14 +151,10 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
); );
function resolveOpenCodeBinary(): string { function resolveOpenCodeBinary(): string {
try { const opencodePath = findExecutable("opencode");
const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim();
if (opencodePath) { if (opencodePath) {
return opencodePath; return opencodePath;
} }
} catch {
// fall through
}
throw new Error( throw new Error(
"OpenCode CLI not found. Please install opencode globally so Paseo can launch the provider." "OpenCode CLI not found. Please install opencode globally so Paseo can launch the provider."
); );