Skip to content

Conversation

@Hona
Copy link
Contributor

@Hona Hona commented Dec 31, 2025

Summary

  • Add Windows PTY library support at runtime with proper path handling
  • Bundle PTY library for all target platforms in build process
  • Fix desktop build scripts to work cross-platform without requiring env vars

Changes

  • packages/opencode/src/pty/index.ts: Add Windows
    ust_pty.dll detection, normalize paths with forward slashes, quote commands with spaces, add try-catch for better error logging
  • packages/opencode/script/build.ts: Install �un-pty for all OS/CPU combinations, copy platform-specific PTY library to dist
  • packages/desktop/scripts/utils.ts: Add RUST_TARGET fallback for local dev, use s API instead of shell commands, derive PTY library name from target triple (not process.platform)
  • packages/desktop/scripts/predev.ts: Handle .exe extension for Windows binaries

Hona added 3 commits December 31, 2025 11:59
- Add Windows rust_pty.dll to library detection
- Normalize paths with forward slashes for cross-platform compatibility
- Quote commands containing spaces for proper execution
- Wrap spawn in try-catch for better error logging
- Use path.join for resolving PTY library location
- Install bun-pty for all OS/CPU combinations during build
- Copy platform-specific PTY library to dist bin folder
- Add RUST_TARGET fallback for local development without env vars
- Use fs API instead of shell commands for Windows compatibility
- Derive PTY library name from target triple instead of process.platform
- Handle .exe extension for Windows binaries in predev
Copilot AI review requested due to automatic review settings December 31, 2025 02:02
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds Windows support for PTY (pseudo-terminal) functionality and improves cross-platform build scripts. The main focus is on properly detecting and distributing the platform-specific bun-pty native libraries and handling Windows-specific path conventions.

Key Changes:

  • Enhanced PTY library path resolution to support Windows DLL alongside Linux/macOS shared libraries
  • Added path normalization and command quoting for Windows compatibility
  • Updated build scripts to install and distribute PTY libraries for all target platforms
  • Improved desktop build utilities with automatic RUST_TARGET detection and cross-platform filesystem operations

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 13 comments.

File Description
packages/opencode/src/pty/index.ts Adds Windows-specific PTY library loading, path normalization with forward slashes, command quoting for paths with spaces, and error handling for PTY spawn failures
packages/opencode/script/build.ts Adds bun-pty to cross-platform installation and copies appropriate PTY library for each target platform during build
packages/desktop/scripts/utils.ts Adds RUST_TARGET auto-detection fallback, migrates from shell commands to Node.js fs API, and adds PTY library copying alongside binary
packages/desktop/scripts/predev.ts Updates to use new RUST_TARGET export and adds .exe extension handling for Windows binaries

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 62 to 70
const ptyLib = isWindows
? "rust_pty.dll"
: isLinux
? isArm64
? "librust_pty_arm64.so"
: "librust_pty.so"
: isArm64
? "librust_pty_arm64.dylib"
: "librust_pty.dylib"
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PTY library selection logic is duplicated across three files with slight variations in implementation:

  1. packages/opencode/src/pty/index.ts uses process.platform and process.arch
  2. packages/opencode/script/build.ts uses item.os and item.arch
  3. This file uses string matching on Rust target triples

While the logic produces the same results for current supported platforms, this duplication creates a maintenance burden. If a new platform or architecture is added, or if the PTY library naming convention changes, all three locations need to be updated consistently.

Consider extracting this logic into a shared utility function that takes platform and architecture information and returns the appropriate PTY library name.

Copilot uses AI. Check for mistakes.
Comment on lines +32 to 42
export const RUST_TARGET =
Bun.env.RUST_TARGET ||
Bun.env.TAURI_ENV_TARGET_TRIPLE ||
(process.platform === "win32"
? "x86_64-pc-windows-msvc"
: process.platform === "darwin"
? process.arch === "arm64"
? "aarch64-apple-darwin"
: "x86_64-apple-darwin"
: "x86_64-unknown-linux-gnu")

Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback logic for RUST_TARGET assumes x86_64-unknown-linux-gnu for any non-Windows, non-Darwin platform. This means that less common platforms (like FreeBSD, OpenBSD, or other Unix-like systems) would be incorrectly mapped to Linux. Additionally, the logic doesn't account for 32-bit platforms or other architectures like RISC-V or PowerPC.

While these platforms may not be officially supported based on the SIDECAR_BINARIES configuration, the fallback could silently produce an incorrect target that fails later, rather than failing fast with a clear error message.

Consider either adding validation that the computed target is actually in SIDECAR_BINARIES, or documenting that this fallback is intentionally limited to the supported platforms.

Suggested change
export const RUST_TARGET =
Bun.env.RUST_TARGET ||
Bun.env.TAURI_ENV_TARGET_TRIPLE ||
(process.platform === "win32"
? "x86_64-pc-windows-msvc"
: process.platform === "darwin"
? process.arch === "arm64"
? "aarch64-apple-darwin"
: "x86_64-apple-darwin"
: "x86_64-unknown-linux-gnu")
function resolveRustTarget(): string {
const envTarget = Bun.env.RUST_TARGET || Bun.env.TAURI_ENV_TARGET_TRIPLE
let candidate: string
if (envTarget) {
candidate = envTarget
} else {
if (process.platform === "win32") {
candidate = "x86_64-pc-windows-msvc"
} else if (process.platform === "darwin") {
candidate = process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
} else if (process.platform === "linux") {
candidate = process.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
} else {
throw new Error(
`Unsupported platform '${process.platform}' with architecture '${process.arch}'. ` +
`Supported Rust targets: ${SIDECAR_BINARIES.map((b) => b.rustTarget).join(", ")}`
)
}
}
const isSupported = SIDECAR_BINARIES.some((b) => b.rustTarget === candidate)
if (!isSupported) {
throw new Error(
`Unsupported Rust target '${candidate}'. Supported Rust targets: ${SIDECAR_BINARIES.map((b) => b.rustTarget).join(", ")}`
)
}
return candidate
}
export const RUST_TARGET = resolveRustTarget()

Copilot uses AI. Check for mistakes.
Comment on lines +7 to 8
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`

Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the issue in utils.ts, the binary path construction uses process.platform === "win32" to determine whether to add the .exe extension. This checks the platform where the script is running, not the target platform.

When cross-compiling or when RUST_TARGET is set to a different platform than the current one, this could result in an incorrect binary path. The code should check if RUST_TARGET or sidecarConfig.rustTarget indicates a Windows target instead of checking the current platform.

Suggested change
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
const targetTriple = (sidecarConfig as { rustTarget?: string }).rustTarget ?? RUST_TARGET
const isWindowsTarget =
typeof targetTriple === "string"
? targetTriple.toLowerCase().includes("windows")
: process.platform === "win32"
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${isWindowsTarget ? ".exe" : ""}`

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +165
if (fs.existsSync(ptySource)) {
fs.copyFileSync(ptySource, path.join(`dist/${name}/bin`, ptyLib))
}
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code silently skips copying the PTY library if the source file doesn't exist (line 163 check). While this may be intentional for development scenarios where the PTY library isn't built, it could hide build issues in production scenarios where the library is expected to exist.

Consider adding a warning log when the PTY library source is not found, or making this behavior configurable based on whether this is a development or production build. This would help developers identify when the PTY library is missing when it should be present.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +125
let normalizedCommand = command.replaceAll("\\", "/")
// Quote command if it contains spaces (bun-pty doesn't quote the command path)
if (normalizedCommand.includes(" ")) {
normalizedCommand = `"${normalizedCommand}"`
}
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path normalization with replaceAll("\\", "/") converts Windows backslashes to forward slashes. While forward slashes are generally accepted by many Windows APIs, this unconditional conversion could cause issues in some edge cases.

More critically, the quoting logic on lines 123-125 has a flaw: it only checks if the normalized command contains spaces, but doesn't verify if the command is already quoted. This could result in double-quoting (e.g., "C:/Program Files/tool.exe" becoming ""C:/Program Files/tool.exe""), which would break command execution.

Consider checking if the command is already quoted before adding quotes, or use a more robust quoting mechanism that handles existing quotes.

Copilot uses AI. Check for mistakes.
Comment on lines 53 to 61
const dest = `${destDir}/opencode-cli-${target}${target.includes("windows") ? ".exe" : ""}`
await fs.copyFile(source, dest)

console.log(`Copied ${source} to ${dest}`)

const isWindows = target.includes("windows")
const isLinux = target.includes("linux")
const isArm64 = target.startsWith("aarch64")

Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .exe extension is added based on process.platform === "win32", which refers to the platform where the build script is running, not the target platform being built for. This means that if you're cross-compiling (e.g., building a Windows binary on a Linux machine), the .exe extension would not be added, resulting in an incorrect binary filename.

The function should check if the target parameter indicates a Windows target (e.g., target.includes("windows")) rather than checking the current platform.

Suggested change
const dest = `${destDir}/opencode-cli-${target}${target.includes("windows") ? ".exe" : ""}`
await fs.copyFile(source, dest)
console.log(`Copied ${source} to ${dest}`)
const isWindows = target.includes("windows")
const isLinux = target.includes("linux")
const isArm64 = target.startsWith("aarch64")
const isWindows = target.includes("windows")
const isLinux = target.includes("linux")
const isArm64 = target.startsWith("aarch64")
const dest = `${destDir}/opencode-cli-${target}${isWindows ? ".exe" : ""}`
await fs.copyFile(source, dest)
console.log(`Copied ${source} to ${dest}`)

Copilot uses AI. Check for mistakes.
const env = { ...process.env, ...input.env, TERM: "xterm-256color" } as Record<string, string>
log.info("creating session", { id, cmd: command, args, cwd })
let normalizedCommand = command.replaceAll("\\", "/")
// Quote command if it contains spaces (bun-pty doesn't quote the command path)
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 122 states "bun-pty doesn't quote the command path", but this is somewhat misleading. The issue is not just that bun-pty doesn't quote the path - it's that paths with spaces need to be quoted for the shell to interpret them correctly. The comment should be more specific about why quoting is necessary and under what conditions it's applied.

Consider updating the comment to: "Quote command path if it contains spaces, as bun-pty passes the command directly to the shell without automatic quoting"

Suggested change
// Quote command if it contains spaces (bun-pty doesn't quote the command path)
// Quote command path if it contains spaces, as bun-pty passes the command directly to the shell without automatic quoting

Copilot uses AI. Check for mistakes.
Comment on lines 31 to 32
const libPath = path.join(path.dirname(process.execPath), ptyLib)
process.env.BUN_PTY_LIB = libPath
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PTY library path is constructed using path.dirname(process.execPath), which assumes the library is in the same directory as the executable. While this is correct for the standard deployment structure, there's no validation that the computed path actually exists or that the library is accessible.

If the deployment structure changes or if the executable is moved without the library, the PTY functionality will silently fail at runtime when bun-pty tries to load the library.

Consider adding a check to verify that the computed library path exists and is accessible, and log a warning or error if it's not found during initialization, rather than waiting for a PTY spawn to fail.

Copilot uses AI. Check for mistakes.
Comment on lines 18 to 33
const pty = lazy(async () => {
if (!Installation.isLocal()) {
const path = require(
`bun-pty/rust-pty/target/release/${
process.platform === "win32"
? "rust_pty.dll"
: process.platform === "linux" && process.arch === "x64"
? "librust_pty.so"
: process.platform === "darwin" && process.arch === "x64"
? "librust_pty.dylib"
: process.platform === "darwin" && process.arch === "arm64"
? "librust_pty_arm64.dylib"
: process.platform === "linux" && process.arch === "arm64"
? "librust_pty_arm64.so"
: ""
}`,
)
process.env.BUN_PTY_LIB = path
if (!Installation.isLocal() && !process.env.BUN_PTY_LIB) {
const ptyLib =
process.platform === "win32"
? "rust_pty.dll"
: process.platform === "linux"
? process.arch === "arm64"
? "librust_pty_arm64.so"
: "librust_pty.so"
: process.arch === "arm64"
? "librust_pty_arm64.dylib"
: "librust_pty.dylib"

const libPath = path.join(path.dirname(process.execPath), ptyLib)
process.env.BUN_PTY_LIB = libPath
}
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BUN_PTY_LIB environment variable is set in a lazy-loaded function that runs when the pty() function is first called. If multiple calls to pty() happen concurrently before the lazy initialization completes, there could be a race condition where the library path is set multiple times or the library is loaded incorrectly.

While the lazy() function from @opencode-ai/util/lazy likely handles this, it would be worth verifying that it properly handles concurrent calls. Consider adding a comment documenting the thread-safety guarantees of the lazy initialization, or ensure the lazy function implementation is thread-safe.

Copilot uses AI. Check for mistakes.
Bus.publish(Event.Created, { info })
return info
} catch (e) {
log.error("failed to spawn pty", { error: e })
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling logs the error and then re-throws it, which is good practice. However, the error object e is logged directly, which may not serialize well for all error types. Consider logging e instanceof Error ? e.message : String(e) or using a more structured error format to ensure error details are properly captured in logs.

Suggested change
log.error("failed to spawn pty", { error: e })
log.error("failed to spawn pty", {
error: e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : String(e),
})

Copilot uses AI. Check for mistakes.
@Hona Hona closed this Dec 31, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant